From 30836573587079c5e663d7b3122957fc8c70dafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 19 Jan 2016 00:57:55 +0100 Subject: [PATCH] FEATURE: better email in support FEATURE: new incoming_email model FEATURE: infinite scrolling in emails admin FEATURE: new 'emails:import' rake task --- .../admin/controllers/admin-email-all.js.es6 | 3 - .../controllers/admin-email-incomings.js.es6 | 11 + .../admin/controllers/admin-email-logs.js.es6 | 11 + .../controllers/admin-email-received.js.es6 | 9 + .../controllers/admin-email-rejected.js.es6 | 9 + .../admin/controllers/admin-email-sent.js.es6 | 11 +- .../controllers/admin-email-skipped.js.es6 | 7 +- .../javascripts/admin/models/email-log.js.es6 | 15 +- .../admin/models/incoming-email.js.es6 | 29 + .../admin/routes/admin-email-all.js.es6 | 2 - .../admin/routes/admin-email-incomings.js.es6 | 14 + .../admin/routes/admin-email-index.js.es6 | 4 +- .../admin/routes/admin-email-logs.js.es6 | 17 +- .../admin/routes/admin-email-received.js.es6 | 2 + .../admin/routes/admin-email-rejected.js.es6 | 2 + .../admin/routes/admin-route-map.js.es6 | 3 +- .../admin/templates/email-received.hbs | 55 ++ .../admin/templates/email-rejected.hbs | 52 ++ .../{email_sent.hbs => email-sent.hbs} | 4 +- .../{email_skipped.hbs => email-skipped.hbs} | 4 +- .../javascripts/admin/templates/email.hbs | 7 +- .../javascripts/admin/templates/email_all.hbs | 39 - .../admin/views/admin-email-incomings.js.es6 | 14 + .../admin/views/admin-email-logs.js.es6 | 14 + .../admin/views/admin-email-received.js.es6 | 5 + .../admin/views/admin-email-rejected.js.es6 | 5 + .../admin/views/admin-email-sent.js.es6 | 5 + .../admin/views/admin-email-skipped.js.es6 | 5 + .../stylesheets/common/admin/admin_base.scss | 24 + app/controllers/admin/email_controller.rb | 49 +- app/jobs/scheduled/poll_mailbox.rb | 78 +- app/mailers/rejection_mailer.rb | 22 +- app/models/incoming_email.rb | 32 + app/models/topic.rb | 23 +- app/models/user.rb | 3 +- app/serializers/incoming_email_serializer.rb | 32 + app/services/spam_rule/auto_block.rb | 3 +- app/services/spam_rule/flag_sockpuppets.rb | 2 + config/locales/client.en.yml | 21 +- config/locales/server.en.yml | 48 +- config/routes.rb | 3 +- config/site_settings.yml | 2 - .../20151109124147_drop_group_managers.rb | 2 +- .../20160113160742_create_incoming_emails.rb | 24 + ...20160118233631_backfill_incoming_emails.rb | 28 + discourse.sublime-project | 4 +- lib/backup_restore/restorer.rb | 4 +- lib/email/receiver.rb | 512 +++++----- lib/tasks/emails.rake | 56 ++ lib/topic_creator.rb | 2 +- lib/user_name_suggester.rb | 11 +- spec/components/email/receiver_spec.rb | 882 ++++-------------- spec/fixtures/emails/android_gmail.eml | Bin 6759 -> 0 bytes spec/fixtures/emails/attachment.eml | Bin 16787 -> 0 bytes .../fixtures/emails/auto_generated_header.eml | Bin 0 -> 228 bytes .../emails/auto_generated_precedence.eml | Bin 0 -> 214 bytes spec/fixtures/emails/auto_reply.eml | Bin 1424 -> 0 bytes spec/fixtures/emails/bad_destinations.eml | Bin 0 -> 296 bytes spec/fixtures/emails/big5.eml | Bin 1092 -> 0 bytes spec/fixtures/emails/bottom_reply.eml | Bin 6864 -> 0 bytes spec/fixtures/emails/boundary.eml | Bin 2198 -> 0 bytes spec/fixtures/emails/cc.eml | Bin 0 -> 365 bytes spec/fixtures/emails/chinese_reply.eml | Bin 0 -> 319 bytes spec/fixtures/emails/dutch.eml | Bin 888 -> 0 bytes spec/fixtures/emails/empty.eml | Bin 1358 -> 0 bytes spec/fixtures/emails/encoded_display_name.eml | Bin 0 -> 361 bytes spec/fixtures/emails/from_the_future.eml | Bin 0 -> 281 bytes spec/fixtures/emails/gmail_web.eml | Bin 7106 -> 0 bytes spec/fixtures/emails/hebrew.eml | Bin 775 -> 0 bytes spec/fixtures/emails/hebrew_reply.eml | Bin 0 -> 328 bytes spec/fixtures/emails/html_only.eml | Bin 5609 -> 0 bytes spec/fixtures/emails/html_paragraphs.eml | Bin 9710 -> 0 bytes spec/fixtures/emails/html_reply.eml | Bin 0 -> 340 bytes spec/fixtures/emails/inactive_sender.eml | Bin 0 -> 265 bytes spec/fixtures/emails/inline_attachment.eml | Bin 0 -> 4138 bytes spec/fixtures/emails/inline_mixed.eml | Bin 1297 -> 0 bytes spec/fixtures/emails/inline_mixed_replies.eml | Bin 0 -> 492 bytes spec/fixtures/emails/inline_reply.eml | Bin 1955 -> 435 bytes .../emails/insufficient_trust_level.eml | Bin 0 -> 364 bytes spec/fixtures/emails/ios_default.eml | Bin 6794 -> 0 bytes spec/fixtures/emails/iphone_signature.eml | Bin 823 -> 314 bytes spec/fixtures/emails/like.eml | Bin 1723 -> 265 bytes spec/fixtures/emails/missing_message_id.eml | Bin 0 -> 141 bytes .../fixtures/emails/multiple_destinations.eml | Bin 1995 -> 0 bytes spec/fixtures/emails/newlines.eml | Bin 3678 -> 0 bytes spec/fixtures/emails/no_body.eml | Bin 0 -> 197 bytes .../emails/no_body_with_attachments.eml | Bin 0 -> 3779 bytes spec/fixtures/emails/no_content_reply.eml | Bin 1828 -> 0 bytes spec/fixtures/emails/no_return_path.eml | Bin 0 -> 187 bytes .../fixtures/emails/on_date_contact_wrote.eml | Bin 0 -> 412 bytes spec/fixtures/emails/on_wrote.eml | Bin 10565 -> 0 bytes spec/fixtures/emails/original_message.eml | Bin 0 -> 321 bytes .../emails/original_message_context.eml | Bin 842 -> 0 bytes spec/fixtures/emails/outlook.eml | Bin 13108 -> 0 bytes spec/fixtures/emails/paragraphs.cooked | 7 - spec/fixtures/emails/paragraphs.eml | Bin 2096 -> 407 bytes spec/fixtures/emails/plus_one.eml | Bin 1721 -> 0 bytes spec/fixtures/emails/previous.eml | Bin 1456 -> 0 bytes spec/fixtures/emails/previous_replies.eml | Bin 7007 -> 682 bytes spec/fixtures/emails/readonly.eml | Bin 0 -> 352 bytes spec/fixtures/emails/reply_user_matching.eml | Bin 0 -> 319 bytes .../emails/reply_user_not_matching.eml | Bin 0 -> 325 bytes spec/fixtures/emails/signature.eml | Bin 813 -> 278 bytes spec/fixtures/emails/staged_sender.eml | Bin 0 -> 261 bytes spec/fixtures/emails/stranger_not_allowed.eml | Bin 0 -> 358 bytes .../emails/sufficient_trust_level.eml | Bin 0 -> 345 bytes spec/fixtures/emails/text_and_html_reply.eml | Bin 0 -> 534 bytes spec/fixtures/emails/text_reply.eml | Bin 0 -> 286 bytes spec/fixtures/emails/too_many_mentions.eml | Bin 1435 -> 276 bytes spec/fixtures/emails/too_short.eml | Bin 1226 -> 0 bytes spec/fixtures/emails/too_small.eml | Bin 0 -> 266 bytes spec/fixtures/emails/valid_incoming.cooked | 5 - spec/fixtures/emails/valid_incoming.eml | Bin 1237 -> 0 bytes spec/fixtures/emails/valid_reply.cooked | 4 - spec/fixtures/emails/valid_reply.eml | Bin 1859 -> 0 bytes spec/fixtures/emails/via_line.eml | Bin 1133 -> 0 bytes spec/fixtures/emails/windows_8_metro.eml | Bin 11944 -> 0 bytes spec/fixtures/emails/wrong_reply_key.eml | Bin 1960 -> 0 bytes spec/jobs/poll_mailbox_spec.rb | 286 +----- 119 files changed, 1061 insertions(+), 1466 deletions(-) delete mode 100644 app/assets/javascripts/admin/controllers/admin-email-all.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-email-incomings.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-email-logs.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-email-received.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 create mode 100644 app/assets/javascripts/admin/models/incoming-email.js.es6 delete mode 100644 app/assets/javascripts/admin/routes/admin-email-all.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-email-incomings.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-email-received.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-email-rejected.js.es6 create mode 100644 app/assets/javascripts/admin/templates/email-received.hbs create mode 100644 app/assets/javascripts/admin/templates/email-rejected.hbs rename app/assets/javascripts/admin/templates/{email_sent.hbs => email-sent.hbs} (93%) rename app/assets/javascripts/admin/templates/{email_skipped.hbs => email-skipped.hbs} (94%) delete mode 100644 app/assets/javascripts/admin/templates/email_all.hbs create mode 100644 app/assets/javascripts/admin/views/admin-email-incomings.js.es6 create mode 100644 app/assets/javascripts/admin/views/admin-email-logs.js.es6 create mode 100644 app/assets/javascripts/admin/views/admin-email-received.js.es6 create mode 100644 app/assets/javascripts/admin/views/admin-email-rejected.js.es6 create mode 100644 app/assets/javascripts/admin/views/admin-email-sent.js.es6 create mode 100644 app/assets/javascripts/admin/views/admin-email-skipped.js.es6 create mode 100644 app/models/incoming_email.rb create mode 100644 app/serializers/incoming_email_serializer.rb create mode 100644 db/migrate/20160113160742_create_incoming_emails.rb create mode 100644 db/migrate/20160118233631_backfill_incoming_emails.rb create mode 100644 lib/tasks/emails.rake delete mode 100644 spec/fixtures/emails/android_gmail.eml delete mode 100644 spec/fixtures/emails/attachment.eml create mode 100644 spec/fixtures/emails/auto_generated_header.eml create mode 100644 spec/fixtures/emails/auto_generated_precedence.eml delete mode 100644 spec/fixtures/emails/auto_reply.eml create mode 100644 spec/fixtures/emails/bad_destinations.eml delete mode 100644 spec/fixtures/emails/big5.eml delete mode 100644 spec/fixtures/emails/bottom_reply.eml delete mode 100644 spec/fixtures/emails/boundary.eml create mode 100644 spec/fixtures/emails/cc.eml create mode 100644 spec/fixtures/emails/chinese_reply.eml delete mode 100644 spec/fixtures/emails/dutch.eml delete mode 100644 spec/fixtures/emails/empty.eml create mode 100644 spec/fixtures/emails/encoded_display_name.eml create mode 100644 spec/fixtures/emails/from_the_future.eml delete mode 100644 spec/fixtures/emails/gmail_web.eml delete mode 100644 spec/fixtures/emails/hebrew.eml create mode 100644 spec/fixtures/emails/hebrew_reply.eml delete mode 100644 spec/fixtures/emails/html_only.eml delete mode 100644 spec/fixtures/emails/html_paragraphs.eml create mode 100644 spec/fixtures/emails/html_reply.eml create mode 100644 spec/fixtures/emails/inactive_sender.eml create mode 100644 spec/fixtures/emails/inline_attachment.eml delete mode 100644 spec/fixtures/emails/inline_mixed.eml create mode 100644 spec/fixtures/emails/inline_mixed_replies.eml create mode 100644 spec/fixtures/emails/insufficient_trust_level.eml delete mode 100644 spec/fixtures/emails/ios_default.eml create mode 100644 spec/fixtures/emails/missing_message_id.eml delete mode 100644 spec/fixtures/emails/multiple_destinations.eml delete mode 100644 spec/fixtures/emails/newlines.eml create mode 100644 spec/fixtures/emails/no_body.eml create mode 100644 spec/fixtures/emails/no_body_with_attachments.eml delete mode 100644 spec/fixtures/emails/no_content_reply.eml create mode 100644 spec/fixtures/emails/no_return_path.eml create mode 100644 spec/fixtures/emails/on_date_contact_wrote.eml delete mode 100644 spec/fixtures/emails/on_wrote.eml create mode 100644 spec/fixtures/emails/original_message.eml delete mode 100644 spec/fixtures/emails/original_message_context.eml delete mode 100644 spec/fixtures/emails/outlook.eml delete mode 100644 spec/fixtures/emails/paragraphs.cooked delete mode 100644 spec/fixtures/emails/plus_one.eml delete mode 100644 spec/fixtures/emails/previous.eml create mode 100644 spec/fixtures/emails/readonly.eml create mode 100644 spec/fixtures/emails/reply_user_matching.eml create mode 100644 spec/fixtures/emails/reply_user_not_matching.eml create mode 100644 spec/fixtures/emails/staged_sender.eml create mode 100644 spec/fixtures/emails/stranger_not_allowed.eml create mode 100644 spec/fixtures/emails/sufficient_trust_level.eml create mode 100644 spec/fixtures/emails/text_and_html_reply.eml create mode 100644 spec/fixtures/emails/text_reply.eml delete mode 100644 spec/fixtures/emails/too_short.eml create mode 100644 spec/fixtures/emails/too_small.eml delete mode 100644 spec/fixtures/emails/valid_incoming.cooked delete mode 100644 spec/fixtures/emails/valid_incoming.eml delete mode 100644 spec/fixtures/emails/valid_reply.cooked delete mode 100644 spec/fixtures/emails/valid_reply.eml delete mode 100644 spec/fixtures/emails/via_line.eml delete mode 100644 spec/fixtures/emails/windows_8_metro.eml delete mode 100644 spec/fixtures/emails/wrong_reply_key.eml diff --git a/app/assets/javascripts/admin/controllers/admin-email-all.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-all.js.es6 deleted file mode 100644 index 5f456c108fc..00000000000 --- a/app/assets/javascripts/admin/controllers/admin-email-all.js.es6 +++ /dev/null @@ -1,3 +0,0 @@ -import AdminEmailSkippedController from "admin/controllers/admin-email-skipped"; - -export default AdminEmailSkippedController.extend(); diff --git a/app/assets/javascripts/admin/controllers/admin-email-incomings.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-incomings.js.es6 new file mode 100644 index 00000000000..deb7f0771c6 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-email-incomings.js.es6 @@ -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); + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-email-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-logs.js.es6 new file mode 100644 index 00000000000..2506a0cd999 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-email-logs.js.es6 @@ -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); + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 new file mode 100644 index 00000000000..69ebd5e4c49 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 @@ -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}") +}); diff --git a/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 new file mode 100644 index 00000000000..317a669cd0e --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 @@ -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}") +}); diff --git a/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 index 6ea640672c2..d73d640adca 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 @@ -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}") }); diff --git a/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 index b392ea8e987..ae75d187155 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 @@ -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}") }); diff --git a/app/assets/javascripts/admin/models/email-log.js.es6 b/app/assets/javascripts/admin/models/email-log.js.es6 index ce7d8a2420d..2b19eeff4f5 100644 --- a/app/assets/javascripts/admin/models/email-log.js.es6 +++ b/app/assets/javascripts/admin/models/email-log.js.es6 @@ -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))); } }); diff --git a/app/assets/javascripts/admin/models/incoming-email.js.es6 b/app/assets/javascripts/admin/models/incoming-email.js.es6 new file mode 100644 index 00000000000..677fbebbc06 --- /dev/null +++ b/app/assets/javascripts/admin/models/incoming-email.js.es6 @@ -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; diff --git a/app/assets/javascripts/admin/routes/admin-email-all.js.es6 b/app/assets/javascripts/admin/routes/admin-email-all.js.es6 deleted file mode 100644 index be310b9c385..00000000000 --- a/app/assets/javascripts/admin/routes/admin-email-all.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -import AdminEmailLogs from 'admin/routes/admin-email-logs'; -export default AdminEmailLogs.extend({ status: "all" }); diff --git a/app/assets/javascripts/admin/routes/admin-email-incomings.js.es6 b/app/assets/javascripts/admin/routes/admin-email-incomings.js.es6 new file mode 100644 index 00000000000..7eefb322cbb --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-email-incomings.js.es6 @@ -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") }); + } + +}); diff --git a/app/assets/javascripts/admin/routes/admin-email-index.js.es6 b/app/assets/javascripts/admin/routes/admin-email-index.js.es6 index 9e8aa7d8990..1b75e39f6f8 100644 --- a/app/assets/javascripts/admin/routes/admin-email-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-email-index.js.es6 @@ -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' }); } }); diff --git a/app/assets/javascripts/admin/routes/admin-email-logs.js.es6 b/app/assets/javascripts/admin/routes/admin-email-logs.js.es6 index f6cbd90eae4..27791bcaec6 100644 --- a/app/assets/javascripts/admin/routes/admin-email-logs.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-email-logs.js.es6 @@ -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" }); } }); diff --git a/app/assets/javascripts/admin/routes/admin-email-received.js.es6 b/app/assets/javascripts/admin/routes/admin-email-received.js.es6 new file mode 100644 index 00000000000..4bea62c1eb2 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-email-received.js.es6 @@ -0,0 +1,2 @@ +import AdminEmailIncomings from 'admin/routes/admin-email-incomings'; +export default AdminEmailIncomings.extend({ status: "received" }); diff --git a/app/assets/javascripts/admin/routes/admin-email-rejected.js.es6 b/app/assets/javascripts/admin/routes/admin-email-rejected.js.es6 new file mode 100644 index 00000000000..ed662e21182 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-email-rejected.js.es6 @@ -0,0 +1,2 @@ +import AdminEmailIncomings from 'admin/routes/admin-email-incomings'; +export default AdminEmailIncomings.extend({ status: "rejected" }); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 406a0011706..3626ea48d31 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -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' }); }); diff --git a/app/assets/javascripts/admin/templates/email-received.hbs b/app/assets/javascripts/admin/templates/email-received.hbs new file mode 100644 index 00000000000..95e0cb2dc57 --- /dev/null +++ b/app/assets/javascripts/admin/templates/email-received.hbs @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + {{#each email in model}} + + + + + + + {{else}} + + {{/each}} + +
{{i18n 'admin.email.time'}}{{i18n 'admin.email.incoming_emails.from_address'}}{{i18n 'admin.email.incoming_emails.to_addresses'}}{{i18n 'admin.email.incoming_emails.subject'}}
{{i18n 'admin.email.logs.filters.title'}}{{text-field value=filter.from placeholderKey="admin.email.incoming_emails.filters.from_placeholder"}}{{text-field value=filter.to placeholderKey="admin.email.incoming_emails.filters.to_placeholder"}}{{text-field value=filter.subject placeholderKey="admin.email.incoming_emails.filters.subject_placeholder"}}
{{format-date email.created_at}} +
+ {{#if email.user}} + {{#link-to 'adminUser' email.user}} + {{avatar email.user imageSize="tiny"}} + {{email.from_address}} + {{/link-to}} + {{else}} + — + {{/if}} +
+
+ {{#each to in email.to_addresses}} +

{{unbound to}}

+ {{/each}} + {{#each cc in email.cc_addresses}} +

{{unbound cc}}

+ {{/each}} +
+ {{#if email.post_url}} + {{email.subject}} + {{else}} + {{email.subject}} + {{/if}} +
{{i18n 'admin.email.incoming_emails.none'}}
+ +{{conditional-loading-spinner condition=view.loading}} diff --git a/app/assets/javascripts/admin/templates/email-rejected.hbs b/app/assets/javascripts/admin/templates/email-rejected.hbs new file mode 100644 index 00000000000..6e055ffb2fa --- /dev/null +++ b/app/assets/javascripts/admin/templates/email-rejected.hbs @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + {{#each email in model}} + + + + + + + + {{else}} + + {{/each}} + +
{{i18n 'admin.email.time'}}{{i18n 'admin.email.incoming_emails.from_address'}}{{i18n 'admin.email.incoming_emails.to_addresses'}}{{i18n 'admin.email.incoming_emails.subject'}}{{i18n 'admin.email.incoming_emails.error'}}
{{i18n 'admin.email.logs.filters.title'}}{{text-field value=filter.from placeholderKey="admin.email.incoming_emails.filters.from_placeholder"}}{{text-field value=filter.to placeholderKey="admin.email.incoming_emails.filters.to_placeholder"}}{{text-field value=filter.subject placeholderKey="admin.email.incoming_emails.filters.subject_placeholder"}}{{text-field value=filter.error placeholderKey="admin.email.incoming_emails.filters.error_placeholder"}}
{{format-date email.created_at}} +
+ {{#if email.user}} + {{#link-to 'adminUser' email.user}} + {{avatar email.user imageSize="tiny"}} + {{email.from_address}} + {{/link-to}} + {{else}} + — + {{/if}} +
+
+ {{#each to in email.to_addresses}} +

{{unbound to}}

+ {{/each}} + {{#each cc in email.cc_addresses}} +

{{unbound cc}}

+ {{/each}} +
{{email.subject}}{{email.error}}
{{i18n 'admin.email.incoming_emails.none'}}
+ +{{conditional-loading-spinner condition=view.loading}} diff --git a/app/assets/javascripts/admin/templates/email_sent.hbs b/app/assets/javascripts/admin/templates/email-sent.hbs similarity index 93% rename from app/assets/javascripts/admin/templates/email_sent.hbs rename to app/assets/javascripts/admin/templates/email-sent.hbs index 0287492f178..4e4f5dbf77a 100644 --- a/app/assets/javascripts/admin/templates/email_sent.hbs +++ b/app/assets/javascripts/admin/templates/email-sent.hbs @@ -1,4 +1,4 @@ - +
@@ -37,3 +37,5 @@ {{/each}}
{{i18n 'admin.email.sent_at'}}
+ +{{conditional-loading-spinner condition=view.loading}} diff --git a/app/assets/javascripts/admin/templates/email_skipped.hbs b/app/assets/javascripts/admin/templates/email-skipped.hbs similarity index 94% rename from app/assets/javascripts/admin/templates/email_skipped.hbs rename to app/assets/javascripts/admin/templates/email-skipped.hbs index c2c6d261a7e..d983b093785 100644 --- a/app/assets/javascripts/admin/templates/email_skipped.hbs +++ b/app/assets/javascripts/admin/templates/email-skipped.hbs @@ -1,4 +1,4 @@ - +
@@ -37,3 +37,5 @@ {{/each}}
{{i18n 'admin.email.time'}}
+ +{{conditional-loading-spinner condition=view.loading}} diff --git a/app/assets/javascripts/admin/templates/email.hbs b/app/assets/javascripts/admin/templates/email.hbs index e14d8ba4b5e..1a7d5bbfe7b 100644 --- a/app/assets/javascripts/admin/templates/email.hbs +++ b/app/assets/javascripts/admin/templates/email.hbs @@ -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}}
diff --git a/app/assets/javascripts/admin/templates/email_all.hbs b/app/assets/javascripts/admin/templates/email_all.hbs deleted file mode 100644 index c2c6d261a7e..00000000000 --- a/app/assets/javascripts/admin/templates/email_all.hbs +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - {{#each l in model}} - - - - - - - - {{else}} - - {{/each}} - -
{{i18n 'admin.email.time'}}{{i18n 'admin.email.user'}}{{i18n 'admin.email.to_address'}}{{i18n 'admin.email.email_type'}}{{i18n 'admin.email.skipped_reason'}}
{{i18n 'admin.email.logs.filters.title'}}{{text-field value=filter.user placeholderKey="admin.email.logs.filters.user_placeholder"}}{{text-field value=filter.address placeholderKey="admin.email.logs.filters.address_placeholder"}}{{text-field value=filter.type placeholderKey="admin.email.logs.filters.type_placeholder"}}{{text-field value=filter.skipped_reason placeholderKey="admin.email.logs.filters.skipped_reason_placeholder"}}
{{format-date l.created_at}} - {{#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}} - — - {{/if}} - {{l.to_address}}{{l.email_type}}{{l.skipped_reason}}
{{i18n 'admin.email.logs.none'}}
diff --git a/app/assets/javascripts/admin/views/admin-email-incomings.js.es6 b/app/assets/javascripts/admin/views/admin-email-incomings.js.es6 new file mode 100644 index 00000000000..6360f857b71 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-incomings.js.es6 @@ -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)); + } + } +}); diff --git a/app/assets/javascripts/admin/views/admin-email-logs.js.es6 b/app/assets/javascripts/admin/views/admin-email-logs.js.es6 new file mode 100644 index 00000000000..6360f857b71 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-logs.js.es6 @@ -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)); + } + } +}); diff --git a/app/assets/javascripts/admin/views/admin-email-received.js.es6 b/app/assets/javascripts/admin/views/admin-email-received.js.es6 new file mode 100644 index 00000000000..da0d8de157c --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-received.js.es6 @@ -0,0 +1,5 @@ +import AdminEmailIncomingsView from "admin/views/admin-email-incomings"; + +export default AdminEmailIncomingsView.extend({ + templateName: "admin/templates/email-received" +}); diff --git a/app/assets/javascripts/admin/views/admin-email-rejected.js.es6 b/app/assets/javascripts/admin/views/admin-email-rejected.js.es6 new file mode 100644 index 00000000000..b89fe8d46a0 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-rejected.js.es6 @@ -0,0 +1,5 @@ +import AdminEmailIncomingsView from "admin/views/admin-email-incomings"; + +export default AdminEmailIncomingsView.extend({ + templateName: "admin/templates/email-rejected" +}); diff --git a/app/assets/javascripts/admin/views/admin-email-sent.js.es6 b/app/assets/javascripts/admin/views/admin-email-sent.js.es6 new file mode 100644 index 00000000000..d007a79644f --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-sent.js.es6 @@ -0,0 +1,5 @@ +import AdminEmailLogsView from "admin/views/admin-email-logs"; + +export default AdminEmailLogsView.extend({ + templateName: "admin/templates/email-sent" +}); diff --git a/app/assets/javascripts/admin/views/admin-email-skipped.js.es6 b/app/assets/javascripts/admin/views/admin-email-skipped.js.es6 new file mode 100644 index 00000000000..e3c44670992 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin-email-skipped.js.es6 @@ -0,0 +1,5 @@ +import AdminEmailLogsView from "admin/views/admin-email-logs"; + +export default AdminEmailLogsView.extend({ + templateName: "admin/templates/email-skipped" +}); diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 57e7fce2e1d..7318e215d2d 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -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 { diff --git a/app/controllers/admin/email_controller.rb b/app/controllers/admin/email_controller.rb index 5ae6a320566..4f9837e8b72 100644 --- a/app/controllers/admin/email_controller.rb +++ b/app/controllers/admin/email_controller.rb @@ -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 diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb index 552833731ab..2d141e3b1be 100644 --- a/app/jobs/scheduled/poll_mailbox.rb +++ b/app/jobs/scheduled/poll_mailbox.rb @@ -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 diff --git a/app/mailers/rejection_mailer.rb b/app/mailers/rejection_mailer.rb index 1c1a7558b13..2821f9abf6a 100644 --- a/app/mailers/rejection_mailer.rb +++ b/app/mailers/rejection_mailer.rb @@ -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. # diff --git a/app/models/incoming_email.rb b/app/models/incoming_email.rb new file mode 100644 index 00000000000..1ba3fb88c3f --- /dev/null +++ b/app/models/incoming_email.rb @@ -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) +# diff --git a/app/models/topic.rb b/app/models/topic.rb index 39b233977c2..171c7fd066c 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -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], diff --git a/app/models/user.rb b/app/models/user.rb index 0d81fa1b94f..a0669751971 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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) diff --git a/app/serializers/incoming_email_serializer.rb b/app/serializers/incoming_email_serializer.rb new file mode 100644 index 00000000000..53916e24cd5 --- /dev/null +++ b/app/serializers/incoming_email_serializer.rb @@ -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 diff --git a/app/services/spam_rule/auto_block.rb b/app/services/spam_rule/auto_block.rb index 2a5fa9dd66c..8a6cea9f308 100644 --- a/app/services/spam_rule/auto_block.rb +++ b/app/services/spam_rule/auto_block.rb @@ -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 diff --git a/app/services/spam_rule/flag_sockpuppets.rb b/app/services/spam_rule/flag_sockpuppets.rb index b6223df7211..4c96a0687fc 100644 --- a/app/services/spam_rule/flag_sockpuppets.rb +++ b/app/services/spam_rule/flag_sockpuppets.rb @@ -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? && diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 3974e91debe..e026c77c9ba 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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: "ERROR - %{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: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index add5f8fb881..2f628d66e34 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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" diff --git a/config/routes.rb b/config/routes.rb index 60d3c615148..270f749c3d6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/site_settings.yml b/config/site_settings.yml index 8102f9a7ebf..5f0687db9d5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -806,8 +806,6 @@ developer: default: 500 client: true hidden: true - allow_staged_accounts: - default: false embedding: feed_polling_enabled: diff --git a/db/migrate/20151109124147_drop_group_managers.rb b/db/migrate/20151109124147_drop_group_managers.rb index 2e197cfb591..41e07141c1a 100644 --- a/db/migrate/20151109124147_drop_group_managers.rb +++ b/db/migrate/20151109124147_drop_group_managers.rb @@ -10,6 +10,6 @@ class DropGroupManagers < ActiveRecord::Migration end def down - raise ActiveRecord::IrriversableMigration + raise ActiveRecord::IrreversibleMigration end end diff --git a/db/migrate/20160113160742_create_incoming_emails.rb b/db/migrate/20160113160742_create_incoming_emails.rb new file mode 100644 index 00000000000..34502bace5c --- /dev/null +++ b/db/migrate/20160113160742_create_incoming_emails.rb @@ -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 diff --git a/db/migrate/20160118233631_backfill_incoming_emails.rb b/db/migrate/20160118233631_backfill_incoming_emails.rb new file mode 100644 index 00000000000..f2d1f0d7259 --- /dev/null +++ b/db/migrate/20160118233631_backfill_incoming_emails.rb @@ -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 diff --git a/discourse.sublime-project b/discourse.sublime-project index 20854867ba8..6ec5f18c583 100644 --- a/discourse.sublime-project +++ b/discourse.sublime-project @@ -16,9 +16,7 @@ { "path": "script" }, { "path": "spec" }, { "path": "vendor" }, - { "path": "test", - "folder_exclude_patterns": ["fixtures"] - } + { "path": "test" }, ], "settings": { diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index 167c455994f..4d1a4849f49 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -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 diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index bb2ad455ac0..39dd2c324cf 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -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(/^$/, "") } 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 diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake new file mode 100644 index 00000000000..1ab9c71d357 --- /dev/null +++ b/lib/tasks/emails.rake @@ -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= rake emails:import" + exit(2) + elsif password.blank? + puts "ERROR: expecting 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 diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index c4791a2f7ef..9ca18af0113 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -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 diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb index 4819a3f879b..c182db5c252 100644 --- a/lib/user_name_suggester.rb +++ b/lib/user_name_suggester.rb @@ -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) diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 796ce440d4d..7f4d0d16a33 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -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(/
HTML 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 , 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 wrote: - -> 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 -> -> 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 -> . -> - -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 wrote:\n\n> 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 wrote:\n\n> 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(//) - 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 diff --git a/spec/fixtures/emails/android_gmail.eml b/spec/fixtures/emails/android_gmail.eml deleted file mode 100644 index 21c5dde23460ae2e799c20d59ae22cbe2638e5ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6759 zcmeHMYj4{)7X8j&K{(h9P(+p=vB#Drp~ZGO+reh0=w!Da27`c-Xp0R^YAHI7=hyGO zr2LG>aoS=R3#{7@6vfBoeVlWsyya3naKnq>R!7wEO0HfPqR#cg)V!_DjC|yFVN~#e z*)pQ>l1a-=dszz0U(d9jNj_&nwsSpC$<6zlAA)~!Qwyyk+HH5pdlh`dHQnm5)s@J@ zUT@eRg`?qhXZU)~Eo<-Xm%@fqFi7RRhW&P?934#ehMZXSEwenL?~J&jy%GIcKhR#M zdriB&XwZ*(T^e*ooeueRk$vX5jp#=h{M*~LBj^p-`8)mX?|PwT{C9fGYb(@@7JSMU z(o*LG=RLt|^3LdaL_axC(NE~9XZ(h)-`|VIM?#U3ziMT-BYV+X>ApSA%A4^N^&eY@{97RJc6ul(z!pRA8xK_>y4>|r9UyrREx$! zP4#|Zr*vsF)C3_Z#h>MQ`R2nZDE{>pQ$io@Pp4HN4M-dr_V1V?s><*Q2@`XFjyVV4sYTCmR5 zX3*4T;ARI{PEeZ-`1HZou+9ol&gCK;3rwRlIQfGS9}IsGdQk&Dxb@I+xh?<;N^Oib zFywjv?M^u6#83P?!yz6XV|=FL6a7)xzb&!`Z7QPyB5Q;pDKY~EPXU$lk}f%AhJz&n zzyjRvkV@&LcmCRUGhxd`hDj9qX6PJs=Wyo^A_hKjA^cHOR&rKQ1~GGQF&AB695}oF z_uom`ymYnyz>IT~4_ywfSGER|rVz#EV9w#sdK~8b_Y=b#u~79Qt8*hV?g0)WQ*$4U z`pPG#Pbn-DTA2-x!TeKMF<*r{gHw#t7oc-#=kgGoHz019s)1F5ACynN2!I%hzX&P= zS;8{O35hFO2$S^Rwxl(!^jutz!zw+Tdm6m=QS%V#6Y@30aIkjrB1}Fs<-icI>?>&J zxSvi?z^MsaQivHXA=5ByPx+yh?(`+3VU0Lssb9Ph4>Xsou5r3!={uJft@O`{C-;6>2hW#j+7F@qc{Vk0GWf9F>9gf78Yb>D~&U$ z^LuCJaocHaSJKU!GcywjQBjBDzxIefPBLu@Civwkb`wqkZEf{>GcNHV~gfo%DQ4XEPy6=d{47v(*t`a1a|k2DM9g?>0*T~ zY#ZOeX0hE3w2>7#lL3=rrXm2{$x;-c#s|3ZdJ0Su^BJv8j;pUx9yvVN>g$=*8I$e0 zmhCC4tz5Mgw~p2C*aNdHA?6tEH-sNv55j(L)NNO4)}mHe@8w1~w6+${*<-K-$D@(m zPUkNz>I_E1t_Lkzaxp7y^R%`<4sEe%Z<}ze{RBTpne3Q?!Zkpr=Ng$1?j=eqKAD1$ z4wlZ22-Vjl*HWA4qSF};$7BDx7Ju-l+piw|OIQJ_~<@JsBu zvaHl|b_L-om||DJfJ|Q10K5Uvftaqh=x2lRcrgZ8wIz6&l!UbUmGxXzfHEgcM+r#9y1{}SReEYkLg_Ne3y_eR9GwB z5s7h#Hg4j)i8(~SImn+?!MZph{n1z`FOBu4&@nxz0${KGx>o@X?KA}+bz14|R-8Kc zJ7>L(%FdwzJP zaz3oBO=Tg@o>N;phdGkl4R9soC=Q(`?y)fNGPRcX30b1RcL4y`h7Q!BQJSrbGnU`a z4BniB#=xQ|CUcr`^ho$(3r;Bj(>i(Fh@r-xUFID}3Y^KZp6U=MN}Rt9AK z{|w9QUW<@w2EFn32UN2=WSxiv&*>TGKh8JD>;Y>4MUi$krEy+iY=Lu+bQ0od=L^xe zN#-@_0NT{>i=mY)(2N zrH+0Ez?9qj5U{Q_$n6(rc0$JEV+pmEs7Eb0*xw}_-ZRgk?DIZYTwWvub=>oxE%rWI zynw~O8NX+vUFhXCxYi}jrRKC755HNVOZFY$byFN}@89qh O!C#P1|1BX1$iD%zU%K=F diff --git a/spec/fixtures/emails/attachment.eml b/spec/fixtures/emails/attachment.eml deleted file mode 100644 index f25c3d1a4498cc01ed66517c024adca785737e56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16787 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 literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/auto_generated_precedence.eml b/spec/fixtures/emails/auto_generated_precedence.eml new file mode 100644 index 0000000000000000000000000000000000000000..bc82e2b3c2608c9018ec76718417d5ee604786c0 GIT binary patch literal 214 zcmZ9^%W4BL3_#Jnze4-QQ+v_}m=K1%poKsvrF&(fObjzpkQGS(zV52f^}U=AvYWC$ zpj{Fy&LZ6rwdrMAx}D0DU?&1A)PHS*Mzv|4L41aHWN1Ttfe=z`)6*OtLJYy}sT3T@ zziq1Bc2#eF^$GL1bO)uLPQ-nM#$3$p^F;oOv}EA~u?cQ1Y{ce2zpr&Ny4&M*#LRy| Tk;|FXZ@HIK<{`oBF`N4V135%x literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/auto_reply.eml b/spec/fixtures/emails/auto_reply.eml deleted file mode 100644 index 7999c8d78b7da5d36b637aa0c34b2e4e0efac16c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1424 zcmcIkO>f&c5WVYH%&iWxQbc`=t%G>&B#jX_hNT1rf*vSoB(t$eRia$GzrM5`H+9n- z_OK8jkpp|RLy*K|n z-b_~kbpku2Wt7=eXC*Y(cTk<;sD!`O8(GA&+_UG;b5XJ7Jy&s==rVv8(DCc9qnS2~T##P%VP6RrZmz(~lyYvJ~*}#Ve z>&52OkBg`%BR90O5Mw|0nHQH?83hb8Cy(8Wg15Qsniefwvt_;S#QA^esiyB&-%1kM zzjt)U(xJT4Yo$ZtFmywoBeNX;e?sbQ+4myNet5*bMoMgJ`;fhUKBnpPZ>1h57%_$P zEs^c6*^2B+i=M(*4+p3@!bnzck4h>j?D+}YAZMNvMSc{9apVWN;{;hoJReK1WW@!m z>uq;m%gUHw+p-GHn~@&M;b8Q%oaLv@r|ZG>b@t%!OTUgubpNpN?lNYzWa4O+1g+nHBlkghE36P%{+R_FgK+vDF-D1$xs_da?zPGa;}r|J_iS y+)OSsVGDUrR(}lVaW_8ZYcMkJP zdMeqzpuPvVuZly=Rx!Xqq|L9iMx{n5JOO_V zPncoq{0%r4{4`w6;KF(5%$90x$@X#GO580PpgVU+$iv)hIgx#%Qdx2U-#N3Atd!O6 zzQzVu`qayDK+b_d*yb3O*R-Lo3=EQJ#ASWgLh8KgjE1x%z9h>|NsJwF7n O=aFk{mp}t+XTAY%Bw?8V literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/big5.eml b/spec/fixtures/emails/big5.eml deleted file mode 100644 index 4a7b2082486094ded1d875ced4ba22eae0d8b366..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1092 zcmbVLU2oGc6n)RHxNl<`$?U}W+*(arx2eETX)87fA>`Oj;x4hH#A(-mk6U0cgc!pQ z_rX5C=lI-XXCi937Mgg3KloZ8AuMgO<3P1C`^ORik&#{eccNF z8hiq8h8lc?13+;WrWq#Sq8K4~HJdCCEsYsE<<6+7GC1AP+KA5ERMLpEQtPr74VAUW zbThWl!ahzh(Oaa^tz^d`BEeJLV>k`51?FiUL@Xd*qKGq}7feJ6#z_?Y0^hYBJ6D}< zGPvC{!|jgw868vQ&UcRPU4ALsjckPbQFOi3Dg*3UA8a{vCe#S4;&;z*xc3af5yA*L zH=^%pDctGAT0p`y#(o^9#77Yha1^I!gSuCGqnf^yeC#ZSd?lF4U~YSVn*0dFXHa4T z`&59@u(c_HR`&*^f`f~0+Z$TLc|C{&XBVVzLxHuV@Yn)Q|1ngwZTAzTd@Yn6wJ_EI zPisAPF0?Xs&s}a?E3X-9BU{>;JjwS16lMUr>E#jnWw Ni*hu2Mx^UHUjYM&R)+up diff --git a/spec/fixtures/emails/bottom_reply.eml b/spec/fixtures/emails/bottom_reply.eml deleted file mode 100644 index 5fc992971fc2f94932992a0956cf0667675af96d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6864 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 diff --git a/spec/fixtures/emails/boundary.eml b/spec/fixtures/emails/boundary.eml deleted file mode 100644 index 1250fe498b09b363c38c94bca46bd5d788958c71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2198 zcmbW2eQ(<~5XS%Kr?{oSvSF6>V#l^*NpM>?Yr17gQ=`K+3;`{jW5yDxl2j6Z`<)cW zN%PjFHY@=m#e1H+-<{dv!QngSGioE0jKJ@D>;y9u7nnydUxV*;2mP+!@AiGT6m|*k zXS3rm{E2x7{186W5(3Zffj5YPFrrP4H}pLC>3BN(nH@;y1gm20%v1#No}c+)KlCsR zM*P(8_4`4dk2sFPV6Q)#SdG{%xhPUnut1G6L-V57rrcumh_rAkt?zkkiUqmTn767H zLdprxFHqWAqZK7~l~U{wjp1kL985{U+t<@od6NB}1pN5(@cbwzjZuqlo{)IbR`^ zq=AZnIjmI;Lc(r^RfW0~(!#v91!@q6K`D$8@@$vwYgIYhC}m9z z+Bg@2!`A{J)=imy2?u$Whu$!l^X8g+gCH1qUYPfTpvMO|;Z@aIdXTV`y;ahZ0cW=p$SO06iRR)Me`$`+R^^8;h-NH)AkG&x^) z8k&))?zBjEZnHHNCQ~(?43}CFV~nLQDcQDSpyX}l>5QiM5#603*_YDLKtDM-!Ft*0i{Kwsg%qcHA`!&$aNpo*rhaYfpo5c zbcAs>Ji5a{Fmi)d?My)}O+7c67ITCJf!ZKSp|Rn|bT$$QbPcs3uq(aod6E!6siE0? zdS`RmrGVc7%+i*{qr7b4(p(xEL(V&yshs*Pf-f}{KX)okAn|#D#MY0mdg1sfzG72x zna1w*ZLZUpeoqmXFdCzGn7h}Z8}gbH;(4L6RVxOvf*X_craSGZIc*>AfUyZ%7uY;6 zxjqvz@}T*4M|d@!E1i?A0v9&&t1Ebw5kp25A|F>gZ=7z{@Ml@Etg>wH>Q zg~({^(t0~EfolY_RF|?DV3k%2QDD3EhW&)KleT3)b}K3#iPPXf@%ZaKs+$P$$TLB(Mz26fFQ`8mtO%|L@ z797fg(`LaDl6Yyeu<4Jj(mOgEPYL_}o@?C2Zo;m!S*0&(zvo;gZ1acc-tW7|Y}{|; SMpm~?dhFgZ`^|QB9QGgcJH{jc diff --git a/spec/fixtures/emails/cc.eml b/spec/fixtures/emails/cc.eml new file mode 100644 index 0000000000000000000000000000000000000000..73e52c111b6e83f75f2c9cb6742bab16cd1cc459 GIT binary patch literal 365 zcmZvXPfNo<5XJBNDduiBZqh2UYAL1`DilPc_f0x&Rrn<}LjZ|}kT!|w%U z;HaOSv=;8RJ~cFO!bR;kYbeasiYZuF5y6Awe-jm1NEDDFF0e~@?5VP}An3wrXaWZ( zXEQ5KTD=bS2R34%Y7qi4PC$+dnLV;vI>GUu{RHwWcybX6lV5`|HZSaK3Ku3fMr|-9 zw@1BRjuca~*b!w%gy4KPR~sKtzauA4(L$aXRZQQJav~SY0G>fQ>}|}A<9gbx|t+8 zYbA(%)?)E%WeW&6NS(RjGUX3o`B=z|3!&s#B@-SADFj=i_i(^?wHVT5K5L0aKV3bT zZpzlC$M_YUH)Iu;Bf;*-2DBmGT~32N;8(albztlb*GCwpLNzys^bd1jeTy!>x0?3G r9u#lDW0o+M(j%o%mwA4fuQuC~Hrw2s>$i8TyAQ0NyC^E`XH~IpUg2eN literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/dutch.eml b/spec/fixtures/emails/dutch.eml deleted file mode 100644 index 7be08dc4938fdf7ef9e5f7c2c79b375cc943bfea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 888 zcmb7CO>fjN6ukRayj!VCQaj&H%c`O)q#_ln?utDiguM3KtV?1?jC_P9!x&b4HeS5r+Dyt}_loeGfIj2d**^cGBsJ1&=iu~r~ zLu_3`9#LYLrfS$8fzg=pEKXPvGn&Dq{T^2L>j%&ZY+uQtuV~KG=}BG@_;EI z`>}Ep?1XM(X}d*$ z?!t0L;t-JD_F7)%G)sTiy@2bS1>lx%(?X`%*P>7|Q9Fr6#aLAozratU7v!C@T@9O= z88#>8zv#F#(c{U{+2#LbyVo5?pU@53ni^Ofdf1q@Cww*tI{ zV`vHVcJQDLoZY(P;6)2>+Y!&msYuVEz(EUm83EV-SSr!?bAm!0(1fVb2Lobj?SkA| zunL~WRzG)=(K<_OzS9Klcura#xf+T6!l?%3!cWFp)jy^fBlZ_c)Q*- z((c281jBH3=iGbFJquJ_Bg{EdO9~&pv1hzuMUO)7qT*|`q?DJ&0(0a&78J@xu7T(H z%td9riiE*aC-??hnLnfYdIs6{7z~5Sw&_~v9m}zZYsQ#yCd`*F5}mi-JH#L8p1~hn zE#YpK&d2W|U3R|#abemCaBRIG0kvJ~+hE$BZT}P5n(>v%(Ka`Z>sghQ)e5a#Lms^O z=jnFd52-V>16qceO?6g6bA1NYDNaiGRlT7_Jj=a!dA$@BS>8((*NH9zcma(c-?`%B z)F#7d7{H*%rsYzM(z~Q57fEv0yO8sGiOpy+@yz7Y7&5Nf5phD$vAo=lKHsNjP|5~A zG+3{;=YCX#MH#w*odp>AxzD_)%*t@cFmv+Ay()N{%dTnB!VO#3hfZAnm!4|+{_0yv zLi_I>-LrHouk>2!kSGY;z~{&;$N!&@dOP;LP_rMLu&g`cUC53;<53B24cVElOm|@$p3eDSC59MSWKd%@0 zdGq;Zd~=gMI{ezNBN9G5ZoP-?^!sD}WpVF}q^j%cN6}4_>S~|1+CR1UFO8(jzG1F* z$S6F;M_s@S75fU<^Ob63r=XUt!UDK}cjKcAK&ov3l5X?PnCX@Icu+0{V%f$-3Z>1$ zOt-bptZ<_)>J{VSy#e`>Y3ZraZE|mhp&yy#XLQ3vTVi8gi(D4kNhkzb?Zy`~;n?d$ zli368m!SPYZ~ye4S3DmTD1voEh#pzudA!c7(AO>#5aI`(6Ao?rpatX9OHRZ9gL#~u s?Lg#A2rSIsw-jc#v+Ed3CMpeO7=aXU@Qa;Gc!5nxLCMgklo4hYm`E|#YHF+Tkr&%Gp=lvH%Ldxgn{c_Pde6-ck0p0^HE;! zw&`>}$_A|(?Nv+d4n6lz$RD((wD&k(-MMaQ{98Pc3^ej&yA;TTT;;PB2AY8n0x^Uo z#wlrYjSM;}+5@Ks7hFJvI)^|hb1Lkh3cJ*yl=Lyn_yR4zT~bu)&AF8J4?x1BxIy|+ zg4PYHERuqOV^h+SLZi^uL;W@A2z*B$Z=b&q^@ literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/from_the_future.eml b/spec/fixtures/emails/from_the_future.eml new file mode 100644 index 0000000000000000000000000000000000000000..6e14c442e1d94b76f42ac058ee76e02710e89079 GIT binary patch literal 281 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 L`aM3-j9h;QvXAqo diff --git a/spec/fixtures/emails/hebrew_reply.eml b/spec/fixtures/emails/hebrew_reply.eml new file mode 100644 index 0000000000000000000000000000000000000000..450e2dea2fc69158dd787ae0e8aa3c3c31955814 GIT binary patch literal 328 zcmZvXPiuoP9ER`t6uEc0#6PKKYbl!(I;d>J71=GupBm6ak`&f&KX(|t?E3QX@Vp=3 zFIER{!jBw3cFIX}u?`Ncu%R^b9+lS29GAx6Ct?33YQwRGWx8&XKHtVPl`@Mmk~5jh zHclk#sURKeA^WqULU>rER^4Hm;#Z+@L@2|Aa2oL>#v4KjK__rd3=kZPHI1@EZ%p{x zH48QE(Min^oWVL}G{-a~=-Frw+6VP@S?wOa{rxftr5~^y#d^x`KI<~bZk!d`^bfYUc)0B+I3V^2X!)@C(vnraYf-jmjjjppD`T^5*XF&h} literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/html_only.eml b/spec/fixtures/emails/html_only.eml deleted file mode 100644 index db88f2c38853df4660efede9724a45c115ebe78f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5609 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 FeE}*&Y-s=h literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/inactive_sender.eml b/spec/fixtures/emails/inactive_sender.eml new file mode 100644 index 0000000000000000000000000000000000000000..d22e9507887adcb8359f0f20fd7cd1ff18a025e6 GIT binary patch literal 265 zcmZvXO-lno5Jd0uEBbC`or#|e5<-jt#ej%-@62|RhMn$ZrX%|A-JHC5u2=P_cd}Cz z|Aux<@W6uE_=}c1tXW6hpK%&D`nJn)K$gFtqO?6# x|0G#^mP3NO9h-BnTB!h!?Np%GK`S&iz(O{ItkNjk`7vP6Bey&?fd;nD{Q^e;SA_ro literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/inline_attachment.eml b/spec/fixtures/emails/inline_attachment.eml new file mode 100644 index 0000000000000000000000000000000000000000..52188b2a78719bf4398fda7dab1cb412227ba37d GIT binary patch literal 4138 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 literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/inline_mixed.eml b/spec/fixtures/emails/inline_mixed.eml deleted file mode 100644 index f8be9c7a548ec9d73104525d58cd8d6a3ba85992..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1297 zcmdT?O>f#j5WVlOn0v9k_$%NLi>MT-m6BA1ibE7tYkO>#+Pm(04WYljYf~WU0jcVx z^TFM9}s{5U0yh&xN8njql+ZE$wOct1UiO~ z($<)+S-r7SS;Yik68N5^DLC)wG5&B-Fvx;GR9sElpPLE z!eTr)i%Sne1btYn2dAvZ%rk8&ns-K#sr0V1xQ+iiiplN@__h?XFjm0lqJ&xPee3ch zx%8KGE;vlSr7z%_zOdP#g*0PD_%CbiAdIgOT8m$rItMK|A2YTv+ps_1d(EMDE*SE@ VI7$6MrJ;bfS5dvov3CK->=$@?nyvr< literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/inline_reply.eml b/spec/fixtures/emails/inline_reply.eml index 39625a225da9fcfacfece749e28561c7267afec6..b65518e2aa4b19f9586993fd587ca12b7933588f 100644 GIT binary patch literal 435 zcmZvY%}&EG5QOi3iapaNvC|(?Q=%5CN+1;w3UGGaO=GF?Mz&Mv+hZtjLfX^rYG%H9 z#?U*PJgLw~nD30&)H{!ht#YZRwq$EZt%NlZ+$i_&M@14Gc8AkoT+c2?Vxo1C7koC= zGrb*+^|Tg>@1`{tzp8Ad0!C41h9D+zuPo$TOo4MLaycHsfD6vq2EA8%Ozu`OT0UCT zMCn(qR+&S|Hm1eo6`eO^C5V)>Te1OdNUBp8)dPNntUD-UuRu2{LWS#>YMm5}JzA)G z9DW;%$?$Bh0tgZPVYwN8vwktpjjd_XTTQk$``+O${p%Ec{4 I$bpdT69{OCQ~&?~ literal 1955 zcmd5+O^?$s5WV|X%#8!hN0YYOhEgqJSy-e3S_*qWXyqi6#!F%c+i5Ako{7`$2SP=N z1LP3J&dm7DdvDB^7t0skN7P0rnZVEw*@g5jv8i_6N+mGY-REg8Or~0G(Bn?W)OwL5xtVpG#Q2PI2^|zJHtwB2utoD8&#u{c)C_Fm(ZXZW;4%Rj>kjTLeYu3^{+4lb9SYgH$3#Vd;%u1aAM=3BS& zx83Tea~7- zB5OL1qS0hB3&-IsnnfX>Fy?tj|35s+;x`*K6&LaekTN8uefnW_?oIC?X)Xb;Pe z%885wzP8E|uZnqABav zor=ALzFVIQO&dfoUu)i!{pNclO!?2dD@BQ>p+AGY39JHJ3ey__lqOdgVd<132rZ#= zM)GnjIH~J78+o0r1ezd1b#QUNcK=fFv`emW1r zxLe2VUp%U+{m+617B*Zvnp{oDhK566Sqp9CMT%>Dw#!^;0jgTK4OJ&*kWk)n%8 diff --git a/spec/fixtures/emails/insufficient_trust_level.eml b/spec/fixtures/emails/insufficient_trust_level.eml new file mode 100644 index 0000000000000000000000000000000000000000..4800b432beeedfc364b972c032609763ddff792b GIT binary patch literal 364 zcma)%!AiqG5Qgvj6mu23b(5;dR!h-ZR45dU-Z$GxvXbn?*$LRUcRh$FL72y(*z$d%DQ$u_cW~3mT9oCC4yVWe}yWt;GJMgeEd08FH`-FUMy5y zNRY>Y&=@>48JU-iAA1ypBEj(9HO|oN2MO{I0N}-F<73cFHcT-1poj5 literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/ios_default.eml b/spec/fixtures/emails/ios_default.eml deleted file mode 100644 index 8d4d58feb1685bbe2bcfedbdc0d57a6d87ad7e95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6794 zcmeHLYj4{|7X4iQiVGSWH-RWp&&U)>#A%W~u-O`k(JW9D!H^u06O$ZvW@uae^}RDB zWyx}4xiyN#wuT|_F^~H^=L}!bf?ZNUQ|FxfAZT5zU!+VXyb+QPxyY<{R5hY<&WOr= z2(Cz>s2E=5Owkt^=UG8Z!ir(S%gA~ocl!$eBTGs`2-r|PN%jxKw=WiCToymN1;!ICodd}(W)y$brz1O4h z?DYA_n^~Ln1*znU3g>l|@RU`V56@$!thcPB&QDZG#w#C2Ll3^jpQqOQCjLYd<-*rT}Jm4)=B9jJ?qm zMq__E!hdkQh38>-(JopgRAO`$uUX=b$MebJMTvV2cTy4U3~(3osW-oCnDu?-ywjO9 zIo)3L3v+b(cO8Y`j&g{s|C;zy;R8F%c|}2|1LHP+Ii1X(T9)NFe>hF13>3XqZe0*o z>D(td!AewNBOM|W#=ex(UmLEll54>#MdE^5*3r=ssGLc_f6|sWtguRf{rG;9HeJA$ zb)HlBwPDEx#DZT{u;SP7sVQp-92*2zp-_ zQ?DIbeN@7+XIbB2H6Kfmyrl5#S?Yl0$CMCAF zh7zoz2!5>ErdeSBVB-vcDBV1h8JxWxz^SO2f*(jhDhOD$;=8aF!Ii*b@U2K&)&9U& ziYEE#*$=olwj(WL%KOK8-!OsC5RFJCNS$w*1e5U{~HNIE1F6+u}lPu zX$wx607=`R%RyH)Cfg0h2WqjQ3~Z%GLEBuF#SYl2m9zK~(i--(0zEGZbe5t(m$RvD zha!w#hBo$zA$NtSZbNq$+6LauhBw%*x3i( z31WeZZf@@b?QNudQpK`f2C_zYk!oGg(2hxRkqIqDoP-w~2~cD)`FcE?AwX?@yr)Y$ zdiYL%-x-0cgS?%mTWLFhQMQtN_vDUg0<2K%p3vT>96Q>77Purb>+RN&esnlFJt@?3 zv#*vKiu?O~Wk>tZ^!86Owd|?F#ZXGdT4v?eN9rWku@R$@sDU0EXNL8$d6;-ZA zz>nmTC?f78P$YO#e{!cYcQRg#hIN(MpqSGA+mXNBn_7}~C)YFhhajPm7TW08hNbA&q77IL^L%3bmV#S`N_$$8zb3o)E`YSv1ZDOl&o0$$3>H{lmHTQ zmD1yZA~`R~05g@6iVTo3NimRkKyom)Y_)9HKAOa{las~rwoS&QZHu%)cGLy8?-)}d zrb@OE)U4GOhS2ftw>mL@%dYMm^Ul}x@@L#IbK8iF>T5gNQhXicbVlAJ7O%RRzPq@4 z1F@=&d79O9_f(-h#{LNEo6<6^rpI|pOquQ>A3V(rT2&b}GYs51!X7H8Snvg1rIPCu*1 z?=?wR{hlH>k;WJr{h@=Ii$0e7O5!L@v8!9u!4DBw`kSiJH+Z)vKJJ;);&ncaZl4HH zWlb@?Xwct|9k$Ovnj&W7ljl1Wu{~f@XMa6U?3(faM;6=u{lggMaM6jgI~e2f&gR=E z^TR1*54sOI#6P~oY4y35SBLCxs_I~VeY{T%J0BVzz_oupIar@3_uwC5H=p3$$>)i^ zqxJiOc=)HCy*9f;j+AS6>*LRcpl(a_2OJxAgwXA;OWONZrS{&oTBbW~yEi9LJiQzF Qh}uGb5qkJXDaWyX2Uj%J1^@s6 diff --git a/spec/fixtures/emails/iphone_signature.eml b/spec/fixtures/emails/iphone_signature.eml index d314ad1f1ea0ad2fc0ef792e28e1efac0ab6a395..79183b4a3f039f6e8e37eb3cc2e98cba127c2317 100644 GIT binary patch literal 314 zcmZvYPfNrw6vXfO6z}F?ZB6>mHtMqIQcx&}R`1#DYa49ymLvuH?TrU-!oXaHA2aiY zVRbfnRbi5Fe>Psz>O3A!%B7l?J!>5;5?Ug7RPNtIPZAtn=51FD&2279t?R7jO{E)s z$_rf$Lh*AoVEspBM-?zaow){4!i%zyaZv&1Qe?8oVaEmMtV8eBh{^LYR?Eu6K$QOW zT9ldhtTPKHpXj_HD?y~3J&_G)L(*@TNDufOZZ5Mj_73!<;;8WO(YHy>SU(vLQI-OP n3BjAus`vxKM(b6yndr;d5e9N8WA70PL;L}j4NRx>WGu2DSC(Xk literal 823 zcmbV~Ur*aG6vf~DDem5eR+s!0#Dj`LQfZSAsIA6c-Q?1Ea_q>yS=VnrCmWzi6G*)H z>G<4pf9IB{)lYQT@U0a<=;^AHO3!QCu{F}*48NDu3b;5(P3Y!_RuNw9t=(&UQZCra z*-pU2B!P!f^2Uc7YX}YT?XgDz-JnVJ($VSUU?hc5pBcc;4yKXrc)9F*jr=x5bL}7v=R0&*HM;%e+W=xk{?Mx-Qe=EN9RDP8$D+j#s9(jWT-yXFEmgR&~g~q4UZb z0r5P7kKs9Cn_>3@YZ9;yu{LTSq6`i}8JM;OhrL$lKd@3#P;A>_21yEcH9-=^8N`{$ zQjrz#Daz9*APlq7)aV1jI7zcVboVdhz8Jm^p2vy*|0QEHOV=xcuTo0`7ys0>VBaS$ z=meJ2^7~}li_@;UYf6-~Lr7~kDbt|1mx*R;0zEGCA^S)Ijm{A-aF>n*q)ZC*^s13p&)RgN>Q}3%{9zIW?awM!Tt3+29kzt zy3%U3vSbExIrnkSxt^k`N^VXPwWRRjaylNJ8dJ=W)i|e+m*NpvhAYMwRx+=V>u6N$ z5iKDEbnYw8HXuc|LiKP2Y4aQmo%5!wB-D=OSj064n6iYM`)?#VmftGG@8}-E6H`kV zj^fGLdx)3S2@n@XyL7XLyLHp+ueWcn~E%N-^ImDw*hPpr9T+D{PM;o zy{H#Jr^Zq;!Ta%WR+H{*HmqI9Sh2*iKRx%%*{3r|nUa0tgrH+t-t@nW<0HsL2_H%< z*3GdW&BJ*fx`CYr82Oo>cu}6_VK2ed$s+e-QWS01KbL*_Z|15ge3@NNLi_bpw@f{^ zn0rz>=Li#JQZPT=3 zyVT;R&|1QXg$^GZBa|{(pm{yeUO7J-tRAP?arx!y?CL7LbJ&GnL?pbuYrNa$=G$HN zbvkzXLKVg0`@FiDEk17fS#0lGrKiTMO8>-6Q7Cme_X8#~QI!&5gerLetl3(XqEb)@ zsc;UA!@IL*6GE(A2(xPQ&KT*FdAE}o1!CF8x!_8B)r^}$BP-mfZm~`ne{VpxOmqs> zzn+cFF!Upn{D>|SE^{o+3!aI&wmgME%hLF2&KTAj=zMew+aqWr>&HJ9#ZN>$%Y+Ay zB_Y~d#Pe8@TA{DkK|qKfcuv@}?HyUhq3I9Ug3e?RAGJkf3FkOBe{U#^u16OG%oD!Q zrSu{Y9CpTT?d%0MC38x8KBdGkhS#4j48s_6JuQB+-7V3{ZoMQ52z4Wvj80ySyJiU- zl41OJ6T(v|wCar0-SsNnKeOAmc38|715DF2PGN}+EUHFoI4m2%5msD0NqE`S&@O9` zRko79wVC(-o2)U>LaUi%3e=KGSTnu?CA1V}A-LZCOQj0h?XE?Zte0AV)U|E9tGX}H zW(#e~YSV4`?Ydj|SU|;PmCCGSDehh%YKb}*wz`)m`<#tdI^0(hODIazqC=jceCWKL OwT=7ZRos!Kll=wVT@&8` diff --git a/spec/fixtures/emails/missing_message_id.eml b/spec/fixtures/emails/missing_message_id.eml new file mode 100644 index 0000000000000000000000000000000000000000..03405bed3cbdc2d01f05cca06e9b4868c33e0cf3 GIT binary patch literal 141 zcmYMry$ZrG5CGskPjS1ZUg{qN5%H&k;3n?Zn#zH>E7uJA_I7jr_!`q3p;3h+8SHx1 zLrYe5n)mKZB~4~Lhp>h#3Dg)ifEYs^S4)^-Ky)|G$v={ftP&wq=uRpnN%8mZF#VD~ ZWih;o=jRAhO(E>6eP2Jjq8zklJ1 zPvtYoMR#&BUbFUie6e;Rgnr*-$s<>U~i zvV@N%7R&m*ALU^_4c)*_1C0F4PrPWFPQy-usgp(SQBo95*DuTN{TEzmgiqK_S!h2; zbu+2Q7N?$)4vT`o4SbG?<@o<&wO+#lFC-QOdn}-8nQb)}+w$o1WHNfK-TeUlL}UCG z**0k!vV9`*$8b&}0X;|P%h}tKnMe`~7viOyTJWdyGlr;CGDCCFC()i9_ZO?H?0xy= z?D*^~y>a-dUqmdtzNx+I`uzJ%_Vw!0=}KJ`vmbeNKAs(IXffOTk~n(CxJtiaremrs zhTA?@nXF2MFhrf)0j~K{m$K4ONTo3cF5unqqf5YqYy!sB!#iV0EAwW{>=>A38z)j| zG81!B7Zh3JgKihggo_UbWb=el=|Hw`etV+g6(#-RCky@coE)+242c8plYp`Jmn5@VGHHJA4^AS@xc5 z(p@bDHqV-L_leHtz_n!<=9auPO2aTN1x-z~Hr5)ow(go}V6hv)XxMr-_BBsnpA^QQ zdl1~0l0;|pw%45^cNkTbE!1C4=G}Xv zB@*PY=89`Vm|$LVu>dVe5~`4bK7Dhoi@4oh$}CyVNeHoP+jd*GpC)8B6Eaox&~A9v zb~oWC5-OppRAnVkar^zG2~sX>^ru?8oHZ#9ZYxETEJ`HuqsUO*9lV~kIrq=AbBC7E F`w1wccH#g4 diff --git a/spec/fixtures/emails/newlines.eml b/spec/fixtures/emails/newlines.eml deleted file mode 100644 index cf03b9d18bcc966fd60979391c1dec0cbaaa78eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3678 zcmeHKQIpz65`NdO7(XTFm_-t>X34Vg-hwccB@Pe>DV2xOXg~wfj3SLzNWVTkVq^PK zIp-eYa@EyQTU%n()7>-u_4hS%-dWQusXMM1p?gUS`7UAw5oKP`4$iWh6tm<(n_x*t z$fMC4G(<}xk*OPfWay*b?Z~)8Kj?#=u1#pdo@q{_)>(xUN%Na3W|F=;3UN%R@^wQ* zs%^^fM2Q@sHF*7b@_WEvAVLD~DUt$xT-B^xq!7IIhdkvE-G9ORgQm{%aXY$KVc+3A zWAcO(%vA;t+LPdta@ld~401@zM6wL$vfD=CSi(tuM4>2og!6hF5=_Zp(AVD%3F$>T zG4z4nr)K~5*3h(0=Zo+EJ(Q#u+07En_>YLh817k)x31M0eo86Fyg1OjGv!1?j2}nn z*HTEh*DPmT;xM6_=Ekf*@R!yoR|UM$=JMrx2vz)Sv0_Pra8d{qQf2;H@T7kIyMeak z*Se~z4&Pi0i)CBe9(uIjAl%*>x@Y_#-j$FNKd@s7Wxn}{lpB#BAG9imBuAR|fYj%@ z4ZhsRQf8xWH^n^4As2-zC_z`|n4G&LVer4F>qEnM`?mYZrKE5>*-4rHl#P26T936w zGd~Gt&+HyHvYBfR^xbSwgl6Ad?;JO>qSMB(www1+GiMcx*!=(rztoKg~5xrkm%j81#Pj_T*RnNgJX?*j}Alykl z@O9#waZF~Sgf%(V_SS`b=J#*r~^9^S*ZGxFI*5V~(=)3C}a)Epn21zdm@X)B-JfmVx^VqXp=j6D@zCD@u zd~a3T=9<~&s#?0ccsW~De(LuFcdYrf-8d&7@M1$A`@PN%s|P3x)LVo!M?+10d#q>y zjrR(pKug#-9^PA%^(4A%EaxfQtrlW_Mn2*Y;C#qqZ0(w!Q%xozyzx$md8>6=b$ds85f>A*2}%=X6G7M|vn z?fJFy1;TJ+iViFXr2*N*#~f!d5{D}tWQ=nf0Xt-jpjLiBc@#B(av@wGj9v%;ik;G; z0G3fyf`PFU>?QF_*7eT6#T=m}~>T1~4i=fJH_R zD53?)Stc3yfz(=*f|{fyXR;o(djWPG7%7BOPH=J%`A-VJ*HutKf;o7mH5`>kD2CzY zv-dSBVo@e=P-Ow0=^rh?{*#NG;s~VzmcCCIKNpHb3+Nm|;gL%64{!~^fqGGjYgEAj z)vb_36mUI?X0I3e%&>}xs+@8XBL;$uAXT+gW*IPg&W^Es1Fk^wWel$rS*s?C1pf}| z#h?mhrfwHbhdGBKhtL#;p|*GbvU$O=`+p_)Tjz%VRVBDWrQ}5!s#XLRIKV8gpbC&~ zhX#OtJ2PCnvjhP_Sx#a21#Hs-_W$kEBeo)M<4Lxj&Fk>ASW=JZyZ37d_}GLz&hhrH z_f5pWGzQlx@}G6!1qn%aFk9}_%%^6+KkEK}QvI>^-{`)+bmz65+I28F)=smn>=Y)* lnbQ1i-*y9-3F6d;>G2GjZ-Ga@2_{MMx$Xm5{N*p7-vC>&#CiY# diff --git a/spec/fixtures/emails/no_body.eml b/spec/fixtures/emails/no_body.eml new file mode 100644 index 0000000000000000000000000000000000000000..02afbe733153d7c56104af99297a30ff37c2af46 GIT binary patch literal 197 zcmZ9^!3x4K3;@u3zan?-Y}F|gk)d;-Fc1;%+03-CCDK*UzjvMl&kx>-;xn*5V!S(8 zH6mGsA*rQXt0G9}pb&u#hCj6Cy>GGb-q ziXei#=j%52-?wf2+b}8u|BP7=HU@Ru|Xp-vG1j^6wy@mwM=bd?CNV06+9=-ml-@ zIbPu1)~@v!&nwi`?>ze}@RbZ(pT+a}^Dp1uKY#w-T>!u4eD!A^Zy={(F%M5NX8!cHAITywXCaM(Fb{jgu$ zCtk7p<3+>6b_0+h9Q5;!Uubadeeel=ffN}o4sRDIzK_OfElRDGN-aj7tenq!OBMI_ zNd>O}C{i`bn3~MfJT_Tn=S++p#G_7CJ3dN}wn#zl{je*iuzCE=Qh28@y%`$<=l}xV zAXY_G8oFd>UFfyb+kO55+5AWFz}eX_ow<#yTo=XO3Y?>ZQ@cso3D;Qw4xvstor|>L zQf{u72S4V&EZBSX^pKa2Uwe1_Fu-<3ubaE8+{p1b)RR+yi&fosjdv_~tIM1GXpH3> zyT!_RjolL>p80xU35{uP;MN{oBYA1MDmVo(3TV7JCqYt&a8xd6beV}7jm(bC!!Zi9 zai85NnNI=!6&0d#cfHnRMTuv38r%Vg=MDz8hm`+CjiKreQ zm5>?+3(A-Xnlp?F7$U_m(@V?)*C z#%jmpD5-|QZOu09-p^BDa5x|@0Y>G{0Ee(#&TIO_tq*a8>fPZGl+j*p(vdI=kfLW;~!ypd*TzD>_)?XP^u!w1W78nu%Zzk71x$Y;6=qNAI#DhVPmic9R1 z#1O8RwNrH}& z#m`t!^RQ}KoReR=+=iGGt#Af>o{x;(_uMCi@UdFKV1g4YMQ=+4*=;yz202C9_1^|+EP1=u)`@)O5Zf;w(UtVo^J zdwVjzD@}3X%qS<}lc32nC}P-X>;x7E@=8Pj)Z7MuHPLm~`2O*>!~7*dbX}`8=HasT zvgpaD1TN_q`js0KzW6fRy46*r?tUcGpF058vcmT$Yzs^H82mbQIpk&+dl3(5jre$!{q8tNqT343jQ?PQh!RY@kt_LVH3|n zQknZ?n$CmWlmK+9fY9Jn7gwSuUIv;S%o)#d4Jdwy+$K9|w5hohll4`?Y zY{oTnBAFL)6VNR8grT|1rf27#ohS!=_vf|70c^844UgpqK2vM}DFa-ZP`HYaS=4=m zQ7ckB);Fo(X)+UOpngDz23^u*!pH;O?@q4T<|D{sBRWNNQ>kfWLPpq3O(IAw!d6mU zI`A1AqxFiGn^oKpu-A5o0s(>n*DKd}I20l)DK8qa8nMNw^n^tFK0rCwLZVJJ2Gd#U zDA^!WDbG82(hpDv!qI-k5^S$MY3SFQ8a z!P0k2d?P@0q;bEiH#zYt_NF@yw>z7IORJtIv^?96T|(pAa5q($XY!BJ0PFgJeb)&d Ph*b6Gzjb8#{s#UFy~V-e literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/no_content_reply.eml b/spec/fixtures/emails/no_content_reply.eml deleted file mode 100644 index 95eb2055ce69c8151f2a7b9e282784f800d1939b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1828 zcmcIlYj2xK6#dSxxcjNL5-`KVz_hmFjngzy+*mP*Dn-!>@Wr0mnW4-;LVx`Zwv)!2 zWTjQLLV{tqoclWW%oVDt%iCpfGSg`Qn?vc3FI@il z+$Q~~A3&$ZQZm5@@x`nrz1i%db|GWM63fBWv1iV{j38x74u}(iwqj%L9oXH9WAk~_99yzeTn1gTj}ltj1z_QJ+f`w zv}F6N$xq>24+*Fx!dNWcADLKC_}xAk6O=Mppm{dd!+AU!ukNq1gYxU?==3zbaoCAp zL?pbvX}s&^{Krl9?dsAQ2vrn|pY!T`wm90ff3dmiAs8C7D*cX`Y9pg?J7zKyRVfiB zsFFLtnypnSDg}j*3g^H$d>B2c0mNDkU{*bTFeZ9s-fS66fmpV2EV$C5Va83Nkrh6w zUa?LX|7bwAOtcF%IGbIXVdzID`DI-uT;^DsCp;5#ttb=%&3faTIb&FBpySCkY?h#P zLT~@lp;vu=Vj&OiOG5PM63=5rYK6X5n}85M@SL!3+gmOe`oaAkR6%>awaAk7QZtXb zwr%%R?-|-`p-ovm_S(kw+zoszpyIMhWmd8jw+EA+O6?08;c+Wqyyu4@^?zX87GJG%e? diff --git a/spec/fixtures/emails/no_return_path.eml b/spec/fixtures/emails/no_return_path.eml new file mode 100644 index 0000000000000000000000000000000000000000..3fae2e6bc84f1c41014591d7093c4676c1da513d GIT binary patch literal 187 zcmYMsy$ZrG6hPrUPjPomP3j+nQi|4sg5V(TX?t4&?Y$+*pl@%SoX_EFJkJIiW;j_7 z#lT#()@L2hrKoMh)Vy1R-owoj&+|lsGd*q$r=CB~20EakEU;(LZ=gVL&f0)N$`SS(sF4B0Gk8V?36JN%c^)TuI)w?3IcIya7LOD@Z9Q7T z*R|Hs$km~23$~XXg&$U-_M)I1D7Y}iX>jF22Ru&`*zAK#3S%0p6T0!Q|iG2jo^{rZd3)*L4y@5{D{zMH+H_)HMCNZh2;(V1`4=-!T;4g8UEb;6*0xT^to&5Jc z?=E=jpk*i1bWG29a4dG;^La0^i{}@gIe(XBm8jg~?WW7Vk)cdp%gE!wg16mf+dXLx zo9z>Ro#+{Vc6s@7#4qRah!6RCwlW2`#+`_+n==g=9(@8J4a@E{laK zuTG~#YBN+ByO32SrqX$S25LWj`icCi{Nf)saWMGd^Y6aDdC~jwm3n#kxAMG7{uzJ$ zqWFFuU47K;Ud;T({JYPdef9OJ*=e=kgnc^?ev+Z~_%|T$$Fn87`~njCH~&OLQ^}K1 zcki$wxe#30R2szq>ql25hyi6nO>Paz4vL6(#x9<3G=phH{HC$+ZYl$sqm`mM+qi zFVvjpQbMkZM~WvEk4qKgNeIIsS@T$_K$KiS^jP~LJ_)4x5=Z};u@||uF7lb-k#!rP z`M`5K{OQFJpQ{hAh)c!q^qR z%GGtnXX?6Eik?9&KPDMG(BoB>q;m0Z^X;YLrK}2-M@P7TQPT%zwIs{sAnbKgB^rm` zBLdwD$ln~}Z;tUcM=OWC%^`2Iz|zpK_oW3BAdmlkdXr;D$U*MOFEovqmgHPAr_6 z1bj6n<5n`^fZ zQkmR%Yl!nW+#?g|#`9}QF>?Eb*?v~PXxqikwG6HjoI*!cnNqNJf;c!^BGN>_t?GrD z`o&Sz&ECpsDHc^IQbX0Rpc|}N_Ps#Dm1WH*yp$l`FFqr5y8Bc-8 zQ>nRs?)t-Xe{kjxyQDkopsNT<;nUE@!405siw%LGvf zkt!oog-sj0tBx$(SrJf(;6=I0v+(_WfgoG7HWp9F>>m-DXF5yYDl(6w9sq>jn5jBM+f>RA9RMJjbQIHg!^%e zh?$Ioa$@LQ!HCK1ULiLr1t<<+WhnrK=tYi!Ct%8a<+HoKH91OS@+Rp7HZ!u>l-83} zkH&(}pmsW)qM%mZe%?Xx0FJ0>(W@X8xzDZ|ll^t$mY6)5Vo%F?ovj!q4DcDU*Klg( z@Uzb-<3cbZ9I$kh(VYWvsn+al=0d!;n`hO`=gDBFfw9chYA! zwFDVsW0Sz`ZtMg~BP;Qad&SKt6F1Iv66u+D(#OsX9?CR@phE0Pw7B+3;WK+q{mc?% zZzhbRC<#=wpewpA?FC&%>(fy*Xk?111L&RR9?F=JJ-G)$8p}`cQIbu0Rfe?tW+LLO zbHQnfG&5~hRSK}Ibh>EfQkyjIm3S?*@L73V9=MSKwz|Dmr|Y&)nngZ^eiLnFzp-Sg z5k`cp<9{8+Agixib^gieWz2R^$qUynk zWQgRHk|Zxj+G!7}<8PqSXm6FzZeLgL?YjN`aJc7CaHkE)lOK`S7;`cvwGC^AGHXR8 zWdfsm^P%~zT*AEb$jSkAK3ioQZ!Dla!1P(0oMp06L)1QBlf2eFu4+c^@{6Sh!y$-R zIGiF6%*mXN;TPj#?L!%GD9Mp<7oJCoy4vEs29RHSn#sA|bf{gkw*3WLn6 zmaW5fu*S1by#^{|6I<;42DTgne%IK7JD(xz2Dkh6g(nt*aY1D}Btp1M3BoF{)Eeyf z`!(E-i^-u?;eDv(vvvQeb!dUOVSj7704IoG2f)s*G47e7MOL5P+7LLDMlxh74sDi< zsA3CQqXG!GMVSgoc~-849Ek90T2eR1sfmoDXoMOPb%QBefE-KLHJ2Of0qX{V@BPB) zZanKwc1z`P3lELf-&NfASz4nLff0=Hj~VwZ;8DmQo}3JKyl@W#MfP$-z>b}8Mb_8osHuAb_eST z)e)mr8_VAA_Ok5Lx}o*NXXcwb`dUo_w1Ywlu8fvhUByDDUxlq)RQDknCoc=!P9=76u0p&Ubb(%W1pebX?lays9n#Y zY-)oU({dJLA5iC&7Pb=GgQerv^C^^-X7FWlorAgsw$MhKe_h*MqYvv`m!j~3Qi>~w z?l)Sfwpy5sZ)X&pu~kX9%c^B`v>cb&G1+|*@|l^|8|m0_8^<8v)?wSuXy_an`)bry zzuOtK)lYZFY@F{7*)Y#NWWmk=_ zOfbqs)~&L7s9IZ&g@c1?ANJ1LJPLnFK#lf?+C1!@_T4*b-BIJtXZBCW-nw;1jXShE zAD9?C*dbUCMW8UTGZuMV>y8?C)cjtm+cfSTllg@)_OXLHo$WHT3^{U4@|cm=f9otezgDq diff --git a/spec/fixtures/emails/original_message.eml b/spec/fixtures/emails/original_message.eml new file mode 100644 index 0000000000000000000000000000000000000000..8dbc5d1820caac59fd38a86ea18e71c57ef80d9a GIT binary patch literal 321 zcmZvXQA@)x6oudOEAFez%%ttQHtHBU3JPN))%V-Q{ZVDE~~67x2fD{T_lAlb6x5t z&2-*L6m8y7aig*WMp~fW+=JY}J6cGD%s~hx6P2Z~5>g0uq!92-{Oz!)CEI=H-1^UJ zL^JHz(Tv2u$p_=C0=X9K)!9fk^7=9@^hiJPVH(ibN6=(6xWlsPA literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/original_message_context.eml b/spec/fixtures/emails/original_message_context.eml deleted file mode 100644 index 31088c16e6f7e07065264b658fb9d20628a57fbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 842 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 diff --git a/spec/fixtures/emails/paragraphs.cooked b/spec/fixtures/emails/paragraphs.cooked deleted file mode 100644 index 2d44722107d..00000000000 --- a/spec/fixtures/emails/paragraphs.cooked +++ /dev/null @@ -1,7 +0,0 @@ -

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.

diff --git a/spec/fixtures/emails/paragraphs.eml b/spec/fixtures/emails/paragraphs.eml index 2d5b5283f7eb0fbd085d58797abfd2d0bc41a9bc..7fb2bd3733e53fb07660e38b8a63f4798302d670 100644 GIT binary patch literal 407 zcmZvY!A`?442JJ{3f~ahTGDPST?b>pG^t1v2o3ID>e7fNDMTiBrRrB81sa4wj<~8jiPcBs-@|= zHYPsxQYhOM+et;yD_JLvE+C0HyEodC_R)Um0^QTmUvw=>y#&!z7C*6frbf}N5_kRJLDzNBx!8AX6vI+_Gnpq6I}q=ts@l(9T#-&%!(0RR91 literal 2096 zcmcIlYfsxq6#edBarYCCV#ki1_|d|`654`DiAX?|5L%7xn`DSH<9fyk?yv8i7%17c zE3K-PBir%ZIrnw$nQPQ_CF~K?^90^Kumv8oe1(F(qUK9 z3od5P50%7=`XbF2=pPXl}W@dQ$?)uGpofNc4+Is9}r z*@LO9;9Z4f(Y)!!d6Z8hKXlU&<6hQdK|D>TQJ-Pj&0_zUm8Hr0$FPt7OI@jjFSVO` zk^8!;n@&BaI1QBSdT|)~VUHtoy1oA~+Q6_Nh=>K@77Hj^&vne@wLJPbnT&px?l!=H zX-s}awv8J@w)aH-1TJVuK+h2da`x+ynaBiww@=m(m0~lr&j&P|rzeBO@;ZA{efn_n z;X``cn%x7gc%ds(2e@8+j7C$)|4$W!0u=; z*)u_8ObE>FznTPw=fkrBPMMfdDE%0ufL1q*S_Gk+czNRWdr9J1*10nDs1TTFsM1W4 zvEj?UEb@II(7ZAC9Std(-vUc*YOQ!76|CpHX!#{@-CA2*0biD~YMY`Z?9E_s`-v@)}@4Hoy`v@FzJ{Om{_#v1t;-M=&3w0Aq04F(m&$X1IZVf*;fZoJ{y{LxT_UbP*zBThay9D Rzx#8q#_sP|c6$yv`vwywnAiXS diff --git a/spec/fixtures/emails/plus_one.eml b/spec/fixtures/emails/plus_one.eml deleted file mode 100644 index a3255e9699ccd0c46181540ceeb84e9fe8f5df9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1721 zcmcIlYj4^}6#edBaraY+5*v>n*q#>DY)H~Xp&)RgN>Q}3%{9zIW?awM!Tt3+29kzt zTB%yCEMS1^b06oN>nWN26ko zXbB;pb6;_`0V%Q-s)r*;o9AHYoHb=7p>{0CBCa{alqKBUe|?0CC~E;^5f&KmuyJ)VIO3J=^|wWseD4n+$Dp*m;x=HWaK-M~%*jQq?`yeLoeu$N%!WRd$ZDT=o1AIl#6&RjKxFSE-@XuqE7mZ|3! zb59C~L_y#NKEuRv{Qt39uT_B;Y83A;v(>ED5D?-Ao)h+Ldq(BZhg2B< z-GcB`3avR~u)AKR`!%<1V~53TF~BrUV*pERU{N(v!(rJ7j1s3q!L*s5Nh>~l6+=Wt(1ETJe-OAdL4@}cu~);8|1 KS8+#{PWBIdBoh_@ diff --git a/spec/fixtures/emails/previous.eml b/spec/fixtures/emails/previous.eml deleted file mode 100644 index 24ac5a63debdfc97d5ad27f6843db63f57c9e9d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1456 zcmb7EU2ohr5Pava*taxDq$BlpC&f7!&7nXF7&V+j{ZIr3MeUs!>qvp5e7Jw#l@z<@ zHgys`2!b@4UCzud+hC_3&|wp8Z3T_?$_~zBM)wy&v77@Uc&WoRq7ILa=di;t}ic)7QB z-{C>(E>`xiCg_e1XB2uu2HR`3$a$7N?|TgwxgcO&)@h;A?5-#pl{7nr#Y%`(QJld? z>NWe#*+T`lQ)RdvD}Setk0!bq6`fT6*=kq%fYG1mytbwS5t9ydd(Ad7Aa(I?%|t$F z#^DVYoU<$RUhXmaxFHF$yj+T8xh%7U7a|qKvb-G3y|G7g@O#~?*{7lYf+|#SL)?FD z9s}_i3?5-h1q2HxN(uDV2havyeQ+lqWCy=?1HNKokY-chK}&d|fQugtmF)Y;LD@W@ zA*(Tv0I{`p%|2KY=v}lu_0-;B=z{L03yaAKBLk)H9n^L(jdZ8?oC_(0N;o!3(~sp?CP8?ck?*82qR^y_W>?<2O$ojOPv`!5lUQ z(Tf+m{x2o-_oois6x@q8guYrVp3T!@@MQYp47MI8<3tnkwR@^BXH(*7hIUSyJ-kFV zA=dB>9s{;((4Et@(%j#9)jBzTc!~BI@|^yU9n2dAB&pm!@&i5L-18ijNre|noZ-$3 zd{20aBOS^2*3fEs%I`ncfprv9uLJw01!1A)a7Q(Tds-d~!L?!21k}zR$)lL;urL@u o)LuDVBkX8Fj8XD92gJXfi_C*F`pPIskGtvrmAyMdty#?e20FgjasU7T diff --git a/spec/fixtures/emails/previous_replies.eml b/spec/fixtures/emails/previous_replies.eml index 3fd74482c07ea05dd3b906592d8dfb4c25c4957b..8b4cbc7e77e5a963581ac7780ba43bc25f5dfa49 100644 GIT binary patch literal 682 zcmZ`%OK;mS48H4E5ZGmD zG5L8T(kA-4@;)7OwT7B2bxSQK4;YxdD?q2Y`r%zf7nAbT33|l8G3y#b?km{Zh^e@~ zFE`0jsj?=9Cu$mSJ_0%0^aq4kBXj{D$@dIH5DA$>uXG0Qa(A@(u#TQ+r@5uiC zT*ugPk!7#j$zt|oVkToFh+dY!op%R;FFkPdom4E+Be~;Xlq#|&iX#@%aQ4ud5Xtt9 z2#|iVW7ye->>sExpzy8L2aLVre$O`09!Dt%-@5`-ax;T$#&bwKmJO_xaYuxc+!yS6 W#xMk035~PJ|5|HL*}MNys-6KTjN;4y literal 7007 zcmeHMZI9cy5&q6!L3q)AkZhX|hr@!BXp31{)KT<$eZRgl zq~!P5N-k|tpx#X&(R>-s%rnnJ?kk>)N3MA4+)1A*Ugql;si+dUR2A<@H7D=hzWc%X zCs&n_g-`oEkGw6M_t?=43A8Ll;tmG4!?8QQz42~eEVyR9y-6bSp3h*FBH@)!cZZzp zO!vB+ne`RZ+^4UV=+eQM{#`!Of!DvG{eeFk`NIKqys_tz56k!yPqa_pL+4*!HM^tl zz|LRkFMpHEV$Of1cf8V~nA4Qc*fQ6YFPO-UO9ZdTYb6&x{m63775$hA&1tYQZ@m@1 zy_icm&&`-#A{P-sf9|hEgA@LZ$oI0)ywJ|wx&-?cu(T+d(yp1jU>f2xr7;+pGPMpp zuh07Z;qzoN7>|biaU6RiZ#?TikCPE0j&t_={iQU2(r%eEQA}x)F$lO0|8e))8544K zbwzb1Dhj{MrUjjW2dMbz1ASv0!^)D7yNuJ{mLhqev68ET&g2vQv|N-Gm5^011*bXt zeN9t2@6q#}$(M9KKQs9QW^86AEo|O&zOX@Z><#pt_3yK1vCPwF&nU)&ih{ml>LCSh z_O^W(%|-D*wWOKWrSH1@i7m>UW2zDIQ-K&r*}97u;ENbsKNtfh01RFFQk6o}A6d={ z8i-;h_W>T!m68A>LL!PE&6Diq&p*-tSE^vCV>r9Z3hIH2Jz4oFf`L2B5`0H2*|h1I z_t!!}1Ee*ZD^_N<@dHm}ksh|*Nf=m_@J+W)JlM6#MGHu&=%c8FhE>j3sKGh|XQzg< zV@5lOK!9^yuZG}mm}d>u&Ll8w3rwTbIs1VgKP&zy#jJ6F>6r6o+`&vSi_bZ}g4Oe_h6Pfhv`Rs#;;?%0;Z8Mo=h7Nk&(kGR46X0rdsk zZi-tWSJwHB@8&{h%NX;>wU4fG)V(QH_Yg7gi3#BwNm<5ON*Tn=e2eL50%It^w7>l> zwJ8veol-%T zusG*!Yk!c6$0&$Z6u9OI|2r!1Avwbb;rD~^r5DVA{-U5Np*mH1o%3+`suQ#1VXlz! zoJ8hQ`Dz}skoOej;lBJ=`fYA&9h)H$K zoSo>nEpS7^4tI_#L#nsu+3Q?S&C-w>W^t5ETKdQI+lh|b0+&VCb3Lt@*|;IjWdf;T z;7XI6RTb`Oj`QA|#?BT#3#R6x@I8t@y)l29#!{sK`<&0T-!Gr&RRWVGw34|G6-^;=_q;OcbqynTdi?PqQqCAMPyTUxC)0eHfY78MCyJC` zV@p@FOfFa#!j-aub%F6&-mQRW9H2ijYnB+sqse4EHHM`Ci}QwopCO6iKmxwIk3qUV zbfVhfhz^8=Ngk6xSRuA~V^**^qtM}v0Tc7tWV`v!hg8CChD1BfM!#~K#5s+&yd>hSK*tOK1wpypxy8MMUA-Fez6UJ172|;*Wz<#$p2H86zRmrH z+w!Dvh^-B>fE8O|YiBbja=R{EAvsxx+8t&P7WFO(}gG;Tdj~Snp856JIV~y9cr`lBTVFi>?S%qG6gO)nwWnpmH~T^4e14&R16B z8f^{f0N=DQ>=fzjZnb8f5DsR@JNP*`xcr}c4!Gx|6$uybF+!5VN%`ze#?d`SYfd^L zrA}UZV9M=Xd^A{t-2URs4#{M4Dxn&QdeVY}^-aRHrR;VWG}l)lK^=Gbnr82k<_lQ- zoAHaxv7%q*7ji~5d?UqxdI1iZ<}V_DSTSH(1l4d+jki(^s2AYGy8ri*E5BB94Y;;L|s5$S(!#g^3$-V=uZVFsHf!p(UeF<{u JcFu9gzW^0*56=Jq diff --git a/spec/fixtures/emails/readonly.eml b/spec/fixtures/emails/readonly.eml new file mode 100644 index 0000000000000000000000000000000000000000..7572bff6685fef754e883e17b4dda888e1910729 GIT binary patch literal 352 zcma)%!Ab)`42JLX6uF9>I@_wqR7=rXR45c(y=SLsJF+`jGg+{2?|9LR2O*ch|9|=N zOgc!i8`Oh`Ya!fnYLANsWgNJho0V|qVZ{u$D1QZN_7G5M&+=0o zbg^R|e~We)+qqfCj_eyrj+{JHV`uI-DJ8Y_u`hI`Pd)2fjA;hpfRagXUh9>;`ppTZ jyd$yqDeyL?y@!v1i=^!(CdH;DV;<-@0{z!pFgr0{z5#V6 literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/reply_user_matching.eml b/spec/fixtures/emails/reply_user_matching.eml new file mode 100644 index 0000000000000000000000000000000000000000..caead84676e020c7689055cf1e4648351b17923e GIT binary patch literal 319 zcmZvY!D_=W5Jd0#ioGXxCEIaqQ9@|bKuZH9gx+O&oeC_ik=D@seVs$;rRSM>Jm!o% z$B=y({?hQ+yJTsO3HP0exh207$4FkokqDkl{I_u-4H2jDwyK79TdIw@scFeP)v?DepyDYYMn^1GKe zZrq8J^O${NOpbyEl}qtVfiZA)xlIe5@tYr}(YWvzL=)10F?$J?dKa!5ww+@k-YKF7 jH>KG_Pa{Q0j=^~3HCPG>E#H3v)4OT8XjuY|j&tz?q2p)U literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/reply_user_not_matching.eml b/spec/fixtures/emails/reply_user_not_matching.eml new file mode 100644 index 0000000000000000000000000000000000000000..c6523f966e4e00747f0e786635bc904880a0cded GIT binary patch literal 325 zcmZ{g!D_=W5Jd0#ioGXxB|C9!Q9@|bKuZIqgkFm*uTz1fHPQx}zpryBJ@q^@kH@?t zUs5dI41a03&lHd%exlE~>rE;xg{?Rw3K|YX@LhHFld?t*vdVR(6f; zY+tWzGb$s8X2kXvEcS+R<|elUwT2fHp^~ZrDYdHfs)mJBQi>zyY|dCb?`J_JcOy}G zQwzrVtvI@X#RsPBC~8op6ps`cBNwOZG}9Ts`EK&Y#lIk#m`6;-Q?xX=c-FA#9SiYF l2?Mw(Ujhu|DM5A&CLk}tQp{-i_7<4IO)lGc3FsY{;s=nfYBT@< literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/signature.eml b/spec/fixtures/emails/signature.eml index 01a0dd7874893fe496742d16bdc1c89f904cf78c..5352f48a259cd2a33ccb3122cada0b4982cb8a1f 100644 GIT binary patch literal 278 zcmZvX(MrQG6hPnaS6rWGX3}=GjXH*og2F(g3O?Rt=^AKrOOk^7dn@Rh?}x)VZ#K=r zi&sj$#`}|thUXCN?nq&6c-%r0c+}W1<0FNCiIz16I}Mjr(bc!P+89%1m8?rsn`2&> zvQs2aWoN5DE9@!RMH<{Sstvx7$4sgcrPM0ZMUE?}q=bWwkPBH!|x?^0xk|x6T0Q0Q-oKowXMdd$ral; z+Y5M{N8oW9dE!AJ_BJ~M#59Ze(M@p3&38u@LA#?Pzx zJgKS;FUsp1p2cOw*Ljiha+6efbzP>#Srx_Au^$Vod_JA=ZOxLzK}WC^#o?{~wu}S-U|Ie3e=nxOh?1ihZA5 z&FqQ&ot;YtzKOrxtpdspXDPt?RGeMRISw6`wKoS%p%R%;w*ErzdY^a8|1Pjjbr;3jjKOhMn?+m#E~hwmJ|53;jX z|Bm*R;Hep&s9zpXyDVklwkjpTRs>e4|M3QmCagSzcn7aoVIJZ=gplGq-QK`eh#|Ng zwKi$}%VsJ(E{`JJB~Xxuh1+o<|4G_dYJ%7Wx0Y&D&F_E4NpAFQ*W-X(|A1m`M^gV> vv-Dg~2_6n?&b^6J0Uq1AKre$RG&aCOHiIm+QMSulz@A5Ld0GMuY@PcBkSA5p literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/stranger_not_allowed.eml b/spec/fixtures/emails/stranger_not_allowed.eml new file mode 100644 index 0000000000000000000000000000000000000000..1464e8ddd84dfd65f28f8f4704baaabc43acdbbb GIT binary patch literal 358 zcma)%!Ait15Qgu0in)rLHQ7~>tS*bLMTLb$tM@dWwvjedlPTD@Hy*@`2VpJ)|9tcP z&&X2@@@Tm;a9#W4Xo?AUm5obB!&VfL1_K2V+}ikOq9g;(GPV@wpF8m~RqyE7K-u{O zc`PuE-a(UjdCBatM@C2-ZD=uyeMaO*(Jw$>z>^KIQu-2HO59Ok3@poe%yh<2J{x=M!v}9gHQlO6R+mNBqQZh`^`2&@ZM4nQWQz9R8wK&AFqa4Ky?NgQ z@)W$hH{5ABk5kh)>(KM2HX*Y#tVJ1U&`=V=r3rsqD$-yLV@qNF35v(5enHC`s?J47 zXMkyR7MfI;#H*)wm?8$TP0{orxCP`VaA!O$RelOe>3pe=3piA{QeuZOnil2lHVrL` zO+%D@jU0^Y*J9@el+PHVBd;OPl(-_#2}D-&nA#aX_-O2n^DD5O2@(0?sVe2!uP+!M m8w~Q=TiQF{YIvK7aWBWvBW@ literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/text_and_html_reply.eml b/spec/fixtures/emails/text_and_html_reply.eml new file mode 100644 index 0000000000000000000000000000000000000000..5fb87780abefdf4c63c7d4b0ddfdca2585b4e94c GIT binary patch literal 534 zcmbV}-AcqT5QXpa6vJNbnl|a5ZMRz%T^1A;M5^~`n%Y2{lr&x3w>L!uMNtsuVqngk z?=bTy>}(W%V7AMlXynB8Y$ma-;OqidXB8J(Fq+ykqWucK1{raf(pY$q8K9 xPQZH6tzpUJ{fE%mq5lSOKRZJs&2H6OvnoIHin?4@3coJjp@r|nC)d$*j=u^YfRvbM~8jj4cNB?i4WeuL@@wO_4W|OPZm^!OvQ<=te zd10zSq3o-H>R+ljV59}=?HW`GFX$kXssbss%5;&#N-8PEi9*0LCC`Utnksh#bNaPw zLOX87$xf8KlMj}i29-+j$S#tLNqd_YdZdqdKaXhLpW)Gkfqe4h4ENTZHEcQ?g=jA} Oz#{x^{k#EvCq4o5BwDfn literal 0 HcmV?d00001 diff --git a/spec/fixtures/emails/too_many_mentions.eml b/spec/fixtures/emails/too_many_mentions.eml index 9cc7b75c94fae5fe175a8acdcadb56706538df87..6940955f9cfdff5bac7708b8487c7d153378af76 100644 GIT binary patch delta 215 zcmZvWO$vfQ7(gLhO^dGZwf2V@9ZiQ22@wi{h%PXWV+rcSj4oPr2-|hpexO~ew|P9? zbN-m!k8&7w?;iMYVX&?mBh;vk%u233p}G_mT6GKxrC`JLU*n`0XnAdKHm#FYKtds6 zKgLNU5>W}%5n#-S&u9QPCK#i=G=`t0yUpKk dgl4r;-e;`iO`A@RWlycOT%c?;QWN;XM=uV_K~?|& literal 1435 zcmcIkO>f&c5WVYH%&iWxQb>J^tpj`QG>s8AhNT1rf*vSoB(t$aRbRFH>zB6d?RwK3 z_OKA3M^S^}%)H09#HOtZeZiX*gAd>M3yyf&VWD=h5gRn5l-Jr46BHe$46;gYKqUAg zirjb=34`ah5L>8a@`BCR3y628U>HnxRa--68m2)leTuQ*g?{{^!DaouMf{1@1^f`r z3hw98V*DPWRr?1}3ubKrCQ%FOF=8=?fKF^e{$1IIi?uG0=v&jWjal@TItg9m&n+gp3*u_n#+5nM4P;HrW?o9b}+Luo{o4~ z?x*`&R@xlvn&+tBP1U8GjHfS~WpY`4z8&A*#!sfWams-Dk54=MaX0(^lzdq}m_yl= zW&Sg5XJI}%q|Fa+CH`wIY~yd3H2caJJWo%$fH^k&D_|$qO(oj~N?A9Uf+*nK_~Zf* zDI0*W-M-W2YNbE*%4I+eqD^GcD6`O`U8yoRxNWY=H5bKu4U!dC(rboy;e)Pb5a{$* zbj6E0!%Dv?5}7I|Vc;pXYhUzGV5bsI=8te#g7OEo{mXw|@qAKX;cY5P)s>~TEy~#N z9py3}rH-d4a0%(Ppq+cknHXTOm_`@-A`)H{nCgG-7|ieHH&e`bk*iYN0AvBZU+i7N o_6VaXqprgk)iiC?)>zRV%pR;B>>ivR+#b9h{2qcHC^;hi3*ZH{Bme*a diff --git a/spec/fixtures/emails/too_short.eml b/spec/fixtures/emails/too_short.eml deleted file mode 100644 index 69f59769787b4f685f2eba3ebc2605419b80b49c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1226 zcmcIjT~Fdb6n*cnxQ`IGb*3M*9mS}C3K1c+8WXb*{kSc&$V}}AEB^W&K-Xo}hke*I zX{XcMJLlYU&J63O6#6u(=M0X2L<>BP(iIA|i#1=OA*DRgW|$yfVagyYeX!g=!zs0E`&fJxMXx{O%NA)phRkiU^FBmSs!B>J;y*+wp9{)k4RAh*8!@-UpP zI?U`4mt|f|nCePdqRwEd#U6#v_<9Dl4eRjr@k+`!>{k|pK+yo6Lh1CcEpag-wBvVN zXsxiUxD?&+I#|)eAh=#xknm!TWp~!I_2B9ZVqRBWYI>k*dA9D}Ou{x~vV`Lji^uhW zfyRPLr93#VY{u5GrqrUAa_1!)76;eu!%|&vY zvoYC)GChNl3J274gucw-_+mC0pJ<^}e^0bAR#lXvKJ2Ry_0IZ>L^B%PJ*XwTp@%}M8l=>!&{YWwsiYKpiV;tgy>90;RctJC{?j$0 z8`fg)M#?@ZM8{r(%B6T_pU9`|c$;QD(N}ty26X;!2Hey folks,

- -

I was thinking. Wouldn't it be great if we could post topics via email? Yes it would!

- -

Jakie

diff --git a/spec/fixtures/emails/valid_incoming.eml b/spec/fixtures/emails/valid_incoming.eml deleted file mode 100644 index cad475d662e4ad302975edb83137612bd667f75a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1237 zcmb7ETXT~@6n^Jd9G^@_p)8j`R#UA>FUd3}6KK_Od|1eVtVwne7FzuEE@`XA+l<2u zEX(0szRNCA)uk{OOsy%rJYUXdC&m&}f@xLDD`3LvPfUbp?%MITmrvF(#Y|^WlvQo>gCJ#INWc!7W#7xSGX_(-#o0 z>*qjRnAQRuTQ5jJZI}8sn6_uzzjn4`d~0&F%{#~StX#@`i&m;z?)~-4$H}7UQ>SkS zGz$}(>a2vOx(3xL4o3LXyrD%r%RPR0JPb9kyoV{yB0UE16_oz?$`$8RoAd|$0D29U z6_;WdUquZ$j-so^g_IX-EQiaHXGWK&kZ@HEi4%g32=O_Pzj3(jx z^ce~5pK;k^{n%vYRnj4YAaDbpBeNX;pY^~g&tPnojNYI zjEffrq-$1IsD_hhZib;hFv*YTl8GwA(mWHXTxsi32(&zm_h!Vg(TPU0YuGPA8%l5g zFzjz+Jd!PfT}g=6IPpAQBv$BaTLy&qf#-yM+rB5vI5gh@W6)cS75~vy~Uvl&mP}`;-#HxWrZqY`dwR8^#1~S*0oEVt=&2yAEFQ xQ~_6zAmpXS6nF-=2I could not disagree more. I am obviously biased but adventure time is the -greatest show ever created. Everyone should watch it.

- -
  • Jake out
diff --git a/spec/fixtures/emails/valid_reply.eml b/spec/fixtures/emails/valid_reply.eml deleted file mode 100644 index 786b1d85169b6de178fa14e89295736a550406e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1859 zcmcIl>2KOd6#wqO;_asrB{m)(_-Kim4M|EA3IY;UilUW|2h0L9u4hc*{`GwZl7=>2 zX|-Be0w2%s_#Mw<)Kw|WQKALE~*n?E?idv9Ggy95Zi9-+hE$BZU5ZaI^ipmqisGpu4m1qoUhQzsO8pgpWOFH zn@;R>>>!?nsU1^R7H3o=rW6MwyftrVG0$?_`@j8Ak>%~DxR}rwz;h`5-i<3RuWicX~6~u=ZqfvvDEg#RALj_}numpUxoVT6LKd0>$!K-TgeA9>GkO@S((F zRUi9N9_F*q4eT_)$j|)5i)QI8>?D{vS>%39ilUkN*S1gp4X#At3wARW+OJXFGW9&- z%u~`~Q4qL+&oQwa|9|Y(YgFKcq=Mi;1=KCGt>$1m9etWkM{kXLn4p(vjDIJ#ZJ9=F zmn8le8cP^cN#R3dfJ!BEH2XdB%K2GuxgKZ7<>#xjtE=?R;TL`pvGDe;_HOIzuXov( z@zCi?T@>?gd38ORf86pj-~N(HPmM{H{*9TAp`>>1dt7C*Diy*2b@BkX;VWIrN<$%) z#vHhScW2KggekcYCe_nBV?bx--A-aLFv~X1rO@P6b6OXaS>uy#7psJe_XcE(gi7dc ze=;<~(2q>^Bf3n4nqg^Ph)m{W`4|F{rSZj_aNJO$^T91_Pl1d~w|^{(Ux;{?i3rvu zW8^L7dAvxi(5H0}Fy;rI6LxHSN0xDD`UAG0HR?@|nkKSD2+Ykt>lg<8!9@>eiI`I> zod~3Wov|A`dx0IZJZ2p~j+tTffnZ(bAf$$z6M-cntYwK7^dVV;OgCIsYE_d56Gb?u z1o_LdgJ#DJM;D9ZoEE&E{BE$&6vlcs7e=QLt!M@D)#sY_?pn zT%_kO1);HZaE%g)ttw%;0nS2RBw@s}kh25^<=1d?d3yz_0@KZ;>t-x1;^9%A2JriL zht&y034H4th!~H7x>{B9;HFz^ zaey%u;0vw;VFHmur$4{gx!2#j0?_X#ITY)6>$->1fwL5 z@`44yS8N&seytpgiGj6nAa@Pk1%Wh`()HR{YwVkg4AzkH!5r@QK@ULHe`@IS+1mTA zoK9QxB7C(f)83Jv(|!D$3O@FAPnzbz_T|FEVEU%g2OgFPwMF599=}FtO-nMsibf-k z-|sGP0!~rXw*ZR=4zwYbL2R~$5rH;dtrXJstwuA)b#UX=b{hkSWGhIap+>(CCz@yX c6}ngcI}o~}6WXjjs*~~bB`q{i8y*F}0GzgOqW}N^ diff --git a/spec/fixtures/emails/windows_8_metro.eml b/spec/fixtures/emails/windows_8_metro.eml deleted file mode 100644 index 67d204af56278f2d9503966a0aac33f84d8cedd2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11944 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 diff --git a/spec/fixtures/emails/wrong_reply_key.eml b/spec/fixtures/emails/wrong_reply_key.eml deleted file mode 100644 index 1c30cfc51fcf4c368ced68714f118fc36551114f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1960 zcmcIlYj4^}6#edBaraY+8XFHb_|Xz28`6|0G$@d$QWUMm_64TEjO!T_++W{oAZcil zU9DCtOE4af@8g_vkFU{`g|vs<%wqWXjnDCzXDgJni$*NbQA!+Rm*8P%(Hu#2rlj29gE)U?FCS#>0~jFrJl%z&yApCA3^xupoB5xb1@N`mXzXWlJs=wm{cjw>;mODm7i8lTyiz zH=o?~hpSH9>bOBX2@^M_s4Pw?M@%91T6nGB&|$vgHFtlzrXt7Rb#XSLHb7)hw0oDH z{B+^6PSgpYvBE+NCA;Ix(TX)kqsx^CsmNzobgxf+d-VAP5@B?gwL+k?Vp4U#T#XN4 zq6+v}V7{o{x1%h~CZQL&Nq|v1ZF4`GB$KeiF=?fdcbo~GsSVLiTz-YeX5kXPJOYhem;-K!`E8f z576TVWZ01dwfT#1D#RB0er+F%Ak!n>147l1L@0F27@J8M8I`*y3` z7?|T)r%D}FM?APgnOFhBDK9i}+$Vp-d zNbS~Fdn9m0fldcEuvr5618x8EpBFqoDo_T?f-xFd=KCT~oUlzU6EM~e{8rd;-K`d^ zeJ|M)12l%c@j+cg%B952{x)jaKh!3O6f$PBy9bnb_qXlW0u9N(~e_iS$&{! zTVx=W0h&ENEf8U;3Ur_kd13hTdl3_KKy2Fp6yUe8kfy~1jE_Y2v?0Nb{$BbZ?Y)3VYVJT?f-rFzi6L#WGi zuK%br@7^0Okl==u(83V91o1-1IT%IbpmQbZvoo!gEi5 :readonly) - category.save - - expect_exception Discourse::InvalidAccess - - poller.handle_mail(email) - expect(email).to be_deleted - end - end - end - - describe "a valid reply" do - let(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' } - let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) } - let(:raw_email) { fill_email(fixture_file("emails/valid_reply.eml"), user.email, to) } - let(:email) { MockPop3EmailObject.new(raw_email) } - let(:expected_post) { fixture_file('emails/valid_reply.cooked') } - let(:topic) { Fabricate(:topic) } - let(:first_post) { Fabricate(:post, user: user, topic: topic, post_number: 1) } - - before do - first_post.save - EmailLog.create(to_address: user.email, - email_type: 'user_posted', - reply_key: reply_key, - user: user, - post: first_post, - topic: topic) - end - - it "creates a new post" do - expect_success - - poller.handle_mail(email) - - new_post = Post.find_by(topic: topic, post_number: 2) - assert new_post.present? - assert_equal expected_post.strip, new_post.cooked.strip - - expect(email).to be_deleted - end - - it "works with multiple To addresses" do - email = MockPop3EmailObject.new fixture_file('emails/multiple_destinations.eml') - expect_success - - poller.handle_mail(email) - - new_post = Post.find_by(topic: topic, post_number: 2) - assert new_post.present? - assert_equal expected_post.strip, new_post.cooked.strip - - expect(email).to be_deleted - end - - describe "with the wrong reply key" do - let(:email) { MockPop3EmailObject.new fixture_file('emails/wrong_reply_key.eml') } - - it "raises an EmailLogNotFound error" do - expect_exception Email::Receiver::EmailLogNotFound - - poller.handle_mail(email) - expect(email).to be_deleted - end - end - end - - describe "when topic is closed" do - let(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' } - let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) } - let(:raw_email) { fill_email(fixture_file("emails/valid_reply.eml"), user.email, to) } - let(:email) { MockPop3EmailObject.new(raw_email) } - let(:topic) { Fabricate(:topic, closed: true) } - let(:first_post) { Fabricate(:post, user: user, topic: topic, post_number: 1) } - - before do - first_post.save - EmailLog.create(to_address: user.email, - email_type: 'user_posted', - reply_key: reply_key, - user: user, - post: first_post, - topic: topic) - end - - describe "should not create post" do - it "raises a TopicClosedError" do - expect_exception Email::Receiver::TopicClosedError - - poller.handle_mail(email) - expect(email).to be_deleted - end - end - end - - describe "when topic is deleted" do - let(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' } - let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) } - let(:raw_email) { fill_email(fixture_file("emails/valid_reply.eml"), user.email, to) } - let(:email) { MockPop3EmailObject.new(raw_email) } - let(:deleted_topic) { Fabricate(:deleted_topic) } - let(:first_post) { Fabricate(:post, user: user, topic: deleted_topic, post_number: 1)} - - before do - first_post.save - EmailLog.create(to_address: user.email, - email_type: 'user_posted', - reply_key: reply_key, - user: user, - post: first_post, - topic: deleted_topic) - end - - describe "should not create post" do - it "raises a TopicNotFoundError" do - expect_exception Email::Receiver::TopicNotFoundError - - poller.handle_mail(email) - expect(email).to be_deleted - end - end - end - - describe "in failure conditions" do - - it "a valid reply without an email log raises an EmailLogNotFound error" do - to = SiteSetting.reply_by_email_address.gsub("%{reply_key}", '59d8df8370b7e95c5a49fbf86aeb2c93') - raw_email = fill_email(fixture_file("emails/valid_reply.eml"), user.email, to) - email = MockPop3EmailObject.new(raw_email) - expect_exception Email::Receiver::EmailLogNotFound - - poller.handle_mail(email) - expect(email).to be_deleted - end - - it "a no content reply raises an EmptyEmailError" do - email = MockPop3EmailObject.new fixture_file('emails/no_content_reply.eml') - expect_exception Email::Receiver::EmptyEmailError - - poller.handle_mail(email) - expect(email).to be_deleted - end - - it "a fully empty email raises an EmptyEmailError" do - email = MockPop3EmailObject.new fixture_file('emails/empty.eml') - expect_exception Email::Receiver::EmptyEmailError - - poller.handle_mail(email) - expect(email).to be_deleted - end - - end - end - end