Better HTML emails, smarter email digests, new email section in admin with digest preview

This commit is contained in:
Robin Ward 2013-06-03 16:12:24 -04:00
parent f030d9b420
commit 0b97ea6345
69 changed files with 552 additions and 443 deletions

View File

@ -1,21 +1,29 @@
/** /**
This controller supports the interface for reviewing email logs. This controller supports email functionality.
@class AdminEmailLogsController @class AdminEmailIndexController
@extends Ember.ArrayController @extends Discourse.Controller
@namespace Discourse @namespace Discourse
@module Discourse @module Discourse
**/ **/
Discourse.AdminEmailLogsController = Ember.ArrayController.extend(Discourse.Presence, { Discourse.AdminEmailIndexController = Discourse.Controller.extend(Discourse.Presence, {
/** /**
Is the "send test email" button disabled? Is the "send test email" button disabled?
@property sendTestEmailDisabled @property sendTestEmailDisabled
**/ **/
sendTestEmailDisabled: function() { sendTestEmailDisabled: Em.computed.empty('testEmailAddress'),
return this.blank('testEmailAddress');
}.property('testEmailAddress'), /**
Clears the 'sentTestEmail' property on successful send.
@method testEmailAddressChanged
**/
testEmailAddressChanged: function() {
this.set('sentTestEmail', false);
}.observes('testEmailAddress'),
/** /**
Sends a test email to the currently entered email address Sends a test email to the currently entered email address
@ -26,7 +34,7 @@ Discourse.AdminEmailLogsController = Ember.ArrayController.extend(Discourse.Pres
this.set('sentTestEmail', false); this.set('sentTestEmail', false);
var adminEmailLogsController = this; var adminEmailLogsController = this;
Discourse.ajax("/admin/email_logs/test", { Discourse.ajax("/admin/email/test", {
type: 'POST', type: 'POST',
data: { email_address: this.get('testEmailAddress') } data: { email_address: this.get('testEmailAddress') }
}).then(function () { }).then(function () {

View File

@ -0,0 +1,21 @@
/**
This controller previews an email digest
@class AdminEmailPreviewDigestController
@extends Discourse.ObjectController
@namespace Discourse
@module Discourse
**/
Discourse.AdminEmailPreviewDigestController = Discourse.ObjectController.extend(Discourse.Presence, {
refresh: function() {
var model = this.get('model');
var controller = this;
controller.set('loading', true);
Discourse.EmailPreview.findDigest(this.get('lastSeen')).then(function (email) {
model.setProperties(email.getProperties('html_content', 'text_content'));
controller.set('loading', false);
})
}
});

View File

@ -18,7 +18,7 @@ Discourse.EmailLog.reopenClass({
findAll: function(filter) { findAll: function(filter) {
var result = Em.A(); var result = Em.A();
Discourse.ajax("/admin/email_logs.json", { Discourse.ajax("/admin/email/logs.json", {
data: { filter: filter } data: { filter: filter }
}).then(function(logs) { }).then(function(logs) {
logs.each(function(log) { logs.each(function(log) {

View File

@ -0,0 +1,21 @@
/**
Our data model for showing a preview of an email
@class EmailPreview
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.EmailPreview = Discourse.Model.extend({});
Discourse.EmailPreview.reopenClass({
findDigest: function(last_seen_at) {
return $.ajax("/admin/email/preview-digest.json", {
data: {last_seen_at: last_seen_at}
}).then(function (result) {
return Discourse.EmailPreview.create(result);
});
}
});

View File

@ -0,0 +1,17 @@
/**
Our data model for representing the current email settings
@class EmailSettings
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.EmailSettings = Discourse.Model.extend({});
Discourse.EmailSettings.reopenClass({
find: function() {
return Discourse.ajax("/admin/email.json").then(function (settings) {
return Discourse.EmailSettings.create(settings);
});
}
});

View File

@ -81,7 +81,7 @@ Discourse.Group.reopenClass({
findAll: function(){ findAll: function(){
var list = Discourse.SelectableArray.create(); var list = Discourse.SelectableArray.create();
Discourse.ajax("/admin/groups").then(function(groups){ Discourse.ajax("/admin/groups.json").then(function(groups){
groups.each(function(group){ groups.each(function(group){
list.addObject(Discourse.Group.create(group)); list.addObject(Discourse.Group.create(group));
}); });

View File

@ -10,10 +10,6 @@ Discourse.AdminApiRoute = Discourse.Route.extend({
model: function() { model: function() {
return Discourse.AdminApi.find(); return Discourse.AdminApi.find();
},
renderTemplate: function() {
this.render({into: 'admin/templates/admin'});
} }
}); });

View File

@ -10,10 +10,6 @@ Discourse.AdminCustomizeRoute = Discourse.Route.extend({
model: function() { model: function() {
return Discourse.SiteCustomization.findAll(); return Discourse.SiteCustomization.findAll();
},
renderTemplate: function() {
this.render({into: 'admin/templates/admin'});
} }
}); });

View File

@ -13,10 +13,6 @@ Discourse.AdminDashboardRoute = Discourse.Route.extend({
this.fetchGithubCommits(c); this.fetchGithubCommits(c);
}, },
renderTemplate: function() {
this.render({into: 'admin/templates/admin'});
},
fetchDashboardData: function(c) { fetchDashboardData: function(c) {
if( !c.get('dashboardFetchedAt') || Date.create('1 hour ago', 'en') > c.get('dashboardFetchedAt') ) { if( !c.get('dashboardFetchedAt') || Date.create('1 hour ago', 'en') > c.get('dashboardFetchedAt') ) {
c.set('dashboardFetchedAt', new Date()); c.set('dashboardFetchedAt', new Date());

View File

@ -0,0 +1,20 @@
/**
Handles email routes
@class AdminEmailRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminEmailIndexRoute = Discourse.Route.extend({
setupController: function(controller) {
Discourse.EmailSettings.find().then(function (model) {
controller.set('model', model);
})
},
renderTemplate: function() {
this.render('admin/templates/email_index', {into: 'adminEmail'});
}
});

View File

@ -12,6 +12,6 @@ Discourse.AdminEmailLogsRoute = Discourse.Route.extend({
}, },
renderTemplate: function() { renderTemplate: function() {
this.render('admin/templates/email_logs'); this.render('admin/templates/email_logs', {into: 'adminEmail'});
} }
}); });

View File

@ -0,0 +1,28 @@
/**
Previews the Email Digests
@class AdminEmailPreviewDigest
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
var oneWeekAgo = function() {
// TODO: Take out due to being evil sugar js?
return Date.create(7 + ' days ago', 'en').format('{yyyy}-{MM}-{dd}');
}
Discourse.AdminEmailPreviewDigestRoute = Discourse.Route.extend(Discourse.ModelReady, {
model: function() {
return Discourse.EmailPreview.findDigest(oneWeekAgo());
},
modelReady: function(controller, model) {
controller.setProperties({
lastSeen: oneWeekAgo(),
showHtml: true
});
}
});

View File

@ -1,13 +0,0 @@
/**
Basic route for admin flags
@class AdminFlagsRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminFlagsRoute = Discourse.Route.extend({
renderTemplate: function() {
this.render('admin/templates/flags');
}
});

View File

@ -1,11 +1,15 @@
/**
Handles routes for admin groups
@class AdminGroupsRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminGroupsRoute = Discourse.Route.extend({ Discourse.AdminGroupsRoute = Discourse.Route.extend({
model: function() { model: function() {
return Discourse.Group.findAll(); return Discourse.Group.findAll();
},
renderTemplate: function() {
this.render('admin/templates/groups',{into: 'admin/templates/admin'});
} }
}); });

View File

@ -1,9 +1,13 @@
/**
Handles routes for admin reports
@class AdminReportsRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
Discourse.AdminReportsRoute = Discourse.Route.extend({ Discourse.AdminReportsRoute = Discourse.Route.extend({
model: function(params) { model: function(params) {
return(Discourse.Report.find(params.type)); return Discourse.Report.find(params.type);
},
renderTemplate: function() {
this.render('admin/templates/reports', {into: 'admin/templates/admin'});
} }
}); });

View File

@ -14,7 +14,11 @@ Discourse.Route.buildRoutes(function() {
this.resource('adminSiteContentEdit', {path: '/:content_type'}); this.resource('adminSiteContentEdit', {path: '/:content_type'});
}); });
this.route('email_logs', { path: '/email_logs' }); this.resource('adminEmail', { path: '/email'}, function() {
this.route('logs', { path: '/logs' });
this.route('previewDigest', { path: '/preview-digest' });
});
this.route('customize', { path: '/customize' }); this.route('customize', { path: '/customize' });
this.route('api', {path: '/api'}); this.route('api', {path: '/api'});

View File

@ -9,9 +9,5 @@
Discourse.AdminSiteSettingsRoute = Discourse.Route.extend({ Discourse.AdminSiteSettingsRoute = Discourse.Route.extend({
model: function() { model: function() {
return Discourse.SiteSetting.findAll(); return Discourse.SiteSetting.findAll();
},
renderTemplate: function() {
this.render('admin/templates/site_settings', {into: 'admin/templates/admin'});
} }
}); });

View File

@ -17,7 +17,7 @@ Discourse.AdminUserRoute = Discourse.Route.extend({
}, },
renderTemplate: function() { renderTemplate: function() {
this.render('admin/templates/user', {into: 'admin/templates/admin'}); this.render({into: 'admin/templates/admin'});
} }
}); });

View File

@ -10,7 +10,7 @@
{{/if}} {{/if}}
<li>{{#linkTo 'adminUsersList.active'}}{{i18n admin.users.title}}{{/linkTo}}</li> <li>{{#linkTo 'adminUsersList.active'}}{{i18n admin.users.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.groups'}}{{i18n admin.groups.title}}{{/linkTo}}</li> <li>{{#linkTo 'admin.groups'}}{{i18n admin.groups.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.email_logs'}}{{i18n admin.email_logs.title}}{{/linkTo}}</li> <li>{{#linkTo 'adminEmail'}}{{i18n admin.email.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}</li> <li>{{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}</li>
{{#if currentUser.admin}} {{#if currentUser.admin}}
<li>{{#linkTo 'admin.customize'}}{{i18n admin.customize.title}}{{/linkTo}}</li> <li>{{#linkTo 'admin.customize'}}{{i18n admin.customize.title}}{{/linkTo}}</li>

View File

@ -0,0 +1,11 @@
<div class='admin-controls'>
<div class='span15'>
<ul class="nav nav-pills">
<li>{{#linkTo adminEmail.index}}{{i18n admin.email.settings}}{{/linkTo}}</li>
<li>{{#linkTo adminEmail.logs}}{{i18n admin.email.logs}}{{/linkTo}}</li>
<li>{{#linkTo adminEmail.previewDigest}}{{i18n admin.email.preview_digest}}{{/linkTo}}</li>
</ul>
</div>
</div>
{{outlet}}

View File

@ -0,0 +1,23 @@
<table class="table">
<tr>
<th>{{i18n admin.email.delivery_method}}</th>
<td>{{model.delivery_method}}</td>
</tr>
{{#each model.settings}}
<tr>
<th style='width: 25%'>{{name}}</th>
<td>{{value}}</td>
</tr>
{{/each}}
</table>
<div class='admin-controls'>
<div class='span5 controls'>
{{textField value=testEmailAddress placeholderKey="admin.email.test_email_address"}}
</div>
<div class='span10 controls'>
<button class='btn' {{action sendTestEmail}} {{bindAttr disabled="sendTestEmailDisabled"}}>{{i18n admin.email.send_test}}</button>
{{#if sentTestEmail}}<span class='result-message'>{{i18n admin.email.sent_test}}</span>{{/if}}
</div>
</div>

View File

@ -1,19 +1,9 @@
<div class='admin-controls'>
<div class='span5 controls'>
{{textField value=testEmailAddress placeholderKey="admin.email_logs.test_email_address"}}
</div>
<div class='span10 controls'>
<button class='btn' {{action sendTestEmail}} {{bindAttr disabled="sendTestEmailDisabled"}}>{{i18n admin.email_logs.send_test}}</button>
{{#if sentTestEmail}}<span class='result-message'>{{i18n admin.email_logs.sent_test}}</span>{{/if}}
</div>
</div>
<table class='table'> <table class='table'>
<tr> <tr>
<th>{{i18n admin.email_logs.sent_at}}</th> <th>{{i18n admin.email.sent_at}}</th>
<th>{{i18n user.title}}</th> <th>{{i18n user.title}}</th>
<th>{{i18n admin.email_logs.to_address}}</th> <th>{{i18n admin.email.to_address}}</th>
<th>{{i18n admin.email_logs.email_type}}</th> <th>{{i18n admin.email.email_type}}</th>
</tr> </tr>
{{#if model.length}} {{#if model.length}}

View File

@ -0,0 +1,29 @@
<div class='admin-controls'>
<div class='span7 controls'>
<label for='last-seen'>{{i18n admin.email.last_seen_user}}</label>
{{input type="date" value=lastSeen id="last-seen"}}
</div>
<div class='span5'>
<button class='btn' {{action refresh}}>{{i18n admin.email.refresh}}</button>
</div>
<div class="span7 toggle">
<label>{{i18n admin.email.format}}</label>
{{#if showHtml}}
<span>{{i18n admin.email.html}}</span> | <a href='#' {{action toggleProperty 'showHtml'}}>{{i18n admin.email.text}}</a>
{{else}}
<a href='#' {{action toggleProperty 'showHtml'}}>{{i18n admin.email.html}}</a> | <span>{{i18n admin.email.text}}</span>
{{/if}}
</div>
</div>
{{#if loading}}
<div class='admin-loading'>{{i18n loading}}</div>
{{else}}
{{#if showHtml}}
{{{html_content}}}
{{else}}
<pre>{{{text_content}}}</pre>
{{/if}}
{{/if}}

View File

@ -34,6 +34,30 @@ Discourse = Ember.Application.createWithMixins({
return u + url; return u + url;
}, },
/**
This custom resolver allows us to find admin templates without calling .render
even though our path formats are slightly different than what ember prefers.
*/
resolver: Ember.DefaultResolver.extend({
resolveTemplate: function(parsedName) {
var resolvedTemplate = this._super(parsedName);
if (resolvedTemplate) { return resolvedTemplate; }
// If we can't find a template, check to see if it's similar to how discourse
// lays out templates like: adminEmail => admin/templates/email
if (parsedName.fullNameWithoutType.indexOf('admin') === 0) {
var decamelized = parsedName.fullNameWithoutType.decamelize();
decamelized = decamelized.replace(/^admin\_/, 'admin/templates/');
decamelized = decamelized.replace(/^admin\./, 'admin/templates/');
decamelized = decamelized.replace(/\./, '_');
resolvedTemplate = Ember.TEMPLATES[decamelized];
if (resolvedTemplate) { return resolvedTemplate; }
}
return Ember.TEMPLATES.not_found;
}
}),
titleChanged: function() { titleChanged: function() {
var title; var title;
title = ""; title = "";

View File

@ -1,26 +0,0 @@
<div class='contents'>
<span class='badge-category' style='background-color: #{{unbound view.color}}; color: #{{unbound view.text_color}}'>{{unbound view.name}}</span>
{{#if view.excerpt}}
<div class='description'>
{{{view.excerpt}}}
<a href="{{unbound view.topic_url}}">{{i18n learn_more}}</a>
</div>
{{/if}}
<div class='figs'>
<figure>{{view.topics_year}} <figcaption>{{i18n year}}</figcaption></figure>
<figure>{{view.topics_month}} <figcaption>{{i18n month}}</figcaption></figure>
<figure>{{view.topics_week}} <figcaption>{{i18n week}}</figcaption></figure>
</div>
</div>
<footer class='button-row'>
{{#if view.can_delete}}
<a href='#' {{action deleteCategory target="view"}} class='btn btn-small'>{{i18n category.delete}}</a>
{{/if}}
{{#if view.can_edit}}
<a href='#' {{action editCategory view}} class='btn btn-small'>{{i18n category.edit}}</a>
{{/if}}
<a href="/category/{{unbound view.slug}}" class='btn btn-small'>{{i18n category.view}}</a>
</footer>

View File

@ -1 +0,0 @@
<a class='close' href='#' {{action close target="view.parentView"}}><i class='icon-white icon-remove'></i></a>

View File

@ -1,21 +0,0 @@
<div class='image'>
{{avatar view imageSize="large"}}
</div>
<div class='contents'>
{{{unbound view.excerpt}}}
<div class='info'>{{unbound view.created_at}}</div>
</div>
<footer class='button-row'>
{{#if view.muted}}
<a href='#' {{action unmute target="view"}} class='btn btn-small'>{{i18n unmute}}</a>
{{else}}
<a href='#' {{action mute target="view"}} class='btn btn-small'>{{i18n mute}}</a>
{{/if}}
{{#if view.has_multiple_posts}}
{{#if view.last_post_url}}
<a href='{{unbound view.last_post_url}}' class='btn btn-small'>{{i18n last_post}}</a>
{{else}}
<a href='{{unbound view.first_post_url}}' class='btn btn-small'>{{i18n first_post}}</a>
{{/if}}
{{/if}}
</footer>

View File

@ -1,10 +0,0 @@
<h1>{{view.name}}</h1>
{{avatar view imageSize="large"}}
<div class='contents'>
{{unbound view.excerpt}}
</div>
<footer class='button-row'>
<a {{action privateMessage target="view"}} href="#" class='btn btn-small'>{{i18n user.private_message}}</a>
<a href='{{unbound view.url}}' class='btn btn-small'>{{i18n user.profile}}</a>
<a href='#' class='btn btn-small' data-not-implemented="true">{{i18n user.mute}}</a>
</footer>

View File

@ -111,6 +111,18 @@
margin-top: 5px; margin-top: 5px;
} }
} }
.toggle {
margin-top: 8px;
float: right;
span {
font-weight: bold;
}
}
label {
display: inline-block;
margin-right: 5px;
}
} }
.settings { .settings {

View File

@ -0,0 +1,37 @@
require_dependency 'email_renderer'
class Admin::EmailController < Admin::AdminController
def index
# For now, just show the ActionMailer settings
mail_settings = { delivery_method: ActionMailer::Base.delivery_method }
mail_settings[:settings] = case mail_settings[:delivery_method]
when :smtp
ActionMailer::Base.smtp_settings.map {|k, v| {name: k, value: v}}
when :sendmail
ActionMailer::Base.sendmail_settings.map {|k, v| {name: k, value: v}}
end
render_json_dump(mail_settings)
end
def test
params.require(:email_address)
Jobs.enqueue(:test_email, to_address: params[:email_address])
render nothing: true
end
def logs
@email_logs = EmailLog.limit(50).includes(:user).order('created_at desc').all
render_serialized(@email_logs, EmailLogSerializer)
end
def preview_digest
params.require(:last_seen_at)
renderer = EmailRenderer.new(UserNotifications.digest(current_user, since: params[:last_seen_at]))
render json: MultiJson.dump(html_content: renderer.html, text_content: renderer.text)
end
end

View File

@ -1,15 +0,0 @@
class Admin::EmailLogsController < Admin::AdminController
def index
@email_logs = EmailLog.limit(50).includes(:user).order('created_at desc').all
render_serialized(@email_logs, EmailLogSerializer)
end
def test
requires_parameter(:email_address)
Jobs.enqueue(:test_email, to_address: params[:email_address])
render nothing: true
end
end

View File

@ -1,40 +0,0 @@
require_dependency 'post_excerpt_serializer'
class ExcerptController < ApplicationController
def show
requires_parameter(:url)
uri = URI.parse(params[:url])
route = Rails.application.routes.recognize_path(uri.path)
case route[:controller]
when 'topics'
# If we have a post number, retrieve the last post. Otherwise, first post.
topic_posts = Post.where(topic_id: route[:topic_id].to_i).order(:post_number)
post = route.has_key?(:post_number) ? topic_posts.last : topic_posts.first
guardian.ensure_can_see!(post)
render json: post, serializer: PostExcerptSerializer, root: false
when 'users'
user = User.where(username_lower: route[:username].downcase).first
guardian.ensure_can_see!(user)
render json: user, serializer: UserExcerptSerializer, root: false
when 'list'
if route[:action] == 'category'
category = Category.where(slug: route[:category]).first
guardian.ensure_can_see!(category)
render json: category, serializer: CategoryExcerptSerializer, root: false
end
else
render nothing: true, status: 404
end
rescue ActionController::RoutingError, Discourse::NotFound
render nothing: true, status: 404
end
end

View File

@ -0,0 +1,12 @@
module UserNotificationsHelper
def indent(text, by=2)
spacer = " " * by
result = ""
text.each_line do |line|
result << spacer << line
end
result
end
end

View File

@ -36,20 +36,18 @@ class UserNotifications < ActionMailer::Base
@user = user @user = user
@base_url = Discourse.base_url @base_url = Discourse.base_url
min_date = @user.last_emailed_at || @user.last_seen_at || 1.month.ago min_date = opts[:since] || @user.last_emailed_at || @user.last_seen_at || 1.month.ago
@site_name = SiteSetting.title @site_name = SiteSetting.title
@last_seen_at = I18n.l(@user.last_seen_at || @user.created_at, format: :short) @last_seen_at = I18n.l(@user.last_seen_at || @user.created_at, format: :short)
# A list of new topics to show the user # A list of topics to show the user
@new_topics = Topic.new_topics(min_date) @new_topics = Topic.for_digest(user, min_date)
@notifications = @user.notifications.interesting_after(min_date)
@markdown_linker = MarkdownLinker.new(Discourse.base_url) @markdown_linker = MarkdownLinker.new(Discourse.base_url)
# Don't send email unless there is content in it # Don't send email unless there is content in it
if @new_topics.present? || @notifications.present? if @new_topics.present?
mail to: user.email, mail to: user.email,
from: "#{I18n.t('user_notifications.digest.from', site_name: SiteSetting.title)} <#{SiteSetting.notification_email}>", from: "#{I18n.t('user_notifications.digest.from', site_name: SiteSetting.title)} <#{SiteSetting.notification_email}>",
subject: I18n.t('user_notifications.digest.subject_template', subject: I18n.t('user_notifications.digest.subject_template',

View File

@ -21,7 +21,6 @@ class Topic < ActiveRecord::Base
versioned if: :new_version_required? versioned if: :new_version_required?
def trash! def trash!
super super
update_flagged_posts_count update_flagged_posts_count
@ -136,6 +135,10 @@ class Topic < ActiveRecord::Base
end end
end end
def best_post
posts.order('score desc').limit(1).first
end
# all users (in groups or directly targetted) that are going to get the pm # all users (in groups or directly targetted) that are going to get the pm
def all_allowed_users def all_allowed_users
# TODO we should probably change this from 3 queries to 1 # TODO we should probably change this from 3 queries to 1
@ -170,15 +173,14 @@ class Topic < ActiveRecord::Base
title_changed? || category_id_changed? title_changed? || category_id_changed?
end end
# Returns new topics since a date for display in email digest. # Returns hot topics since a date for display in email digest.
def self.new_topics(since) def self.for_digest(user, since)
Topic Topic
.visible .visible
.where(closed: false, archived: false) .where(closed: false, archived: false)
.created_since(since) .created_since(since)
.listable_topics .listable_topics
.topic_list_order .order(:percent_rank)
.includes(:user)
.limit(5) .limit(5)
end end

View File

@ -1,34 +0,0 @@
require_dependency 'excerpt_type'
class CategoryExcerptSerializer < ActiveModel::Serializer
include ExcerptType
attributes :excerpt, :name, :color, :text_color, :slug, :topic_url, :topics_year,
:topics_month, :topics_week, :category_url, :can_edit, :can_delete
def topics_year
object.topics_year || 0
end
def topics_month
object.topics_month || 0
end
def topics_week
object.topics_week || 0
end
def category_url
"/category/#{object.slug}"
end
def can_edit
scope.can_edit?(object)
end
def can_delete
scope.can_delete?(object)
end
end

View File

@ -1,11 +0,0 @@
module ExcerptType
def self.included(base)
base.attributes :type
end
def type
self.class.name.sub(/ExcerptSerializer/, '')
end
end

View File

@ -1,37 +0,0 @@
require_dependency 'excerpt_type'
class PostExcerptSerializer < ActiveModel::Serializer
include ExcerptType
attributes :topic_id, :muted, :excerpt, :username, :created_at, :has_multiple_posts, :last_post_url, :first_post_url, :avatar_template
def muted
object.topic.muted?(scope.current_user)
end
def avatar_template
object.user.avatar_template
end
def has_multiple_posts
(object.topic.posts_count > 1)
end
def last_post_url
object.topic.last_post_url
end
def first_post_url
object.topic.relative_url
end
def include_last_post_url?
object.post_number == 1
end
def include_first_post_url?
object.post_number > 1
end
end

View File

@ -1,14 +0,0 @@
require_dependency 'excerpt_type'
class UserExcerptSerializer < ActiveModel::Serializer
include ExcerptType
# TODO: Inherit from basic user serializer?
attributes :bio_cooked, :username, :url, :name, :avatar_template
def url
user_path(object.username.downcase)
end
end

View File

@ -0,0 +1,23 @@
<table style="width: 100%">
<tr>
<td style="background-color: #eee; padding: 30px;">
<center>
<table style="width: 90%; max-width: 500px;">
<tr>
<td>
<a href="<%= Discourse.base_url %>"><img src="<%= Discourse.base_url %><%= SiteSetting.logo_url %>" style="height: 50px; margin-bottom: 15px;"></a>
</td>
</tr>
<tr>
<td style="background-color: #fff; padding: 20px; font-family: Arial, Helvetica, sans-serif; font-size: 14px;">
<%= raw(html_body) %>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>

View File

@ -3,19 +3,21 @@
site_link: site_link, site_link: site_link,
last_seen_at: @last_seen_at) %> last_seen_at: @last_seen_at) %>
<%- if @notifications.present? %>
### <%=t 'user_notifications.digest.new_activity' %>
<%- @notifications.each do |n| %>
* <%= raw(n.text_description { raw(@markdown_linker.create(n.data_hash[:topic_title], n.url)) }) %>
<%- end %>
<%- end %>
<%- if @new_topics.present? %> <%- if @new_topics.present? %>
### <%=t 'user_notifications.digest.new_topics' %> ### <%=t 'user_notifications.digest.top_topics' %>
<%- @new_topics.each do |t| %> <%- @new_topics.each do |t| %>
* <%= raw(@markdown_linker.create(t.title, t.relative_url)) %> <%= raw(@markdown_linker.create(t.title, t.relative_url)) %>
<%- if t.best_post.present? %>
<%= raw(t.best_post.excerpt(1000,
strip_links: true,
text_entities: true)) %>
--------------------------------------------------------------------------------
<%- end %>
<%- end %> <%- end %>
<%- end %> <%- end %>

View File

@ -1016,7 +1016,7 @@ cs:
delete_confirm: "Smazat toto přizpůsobení?" delete_confirm: "Smazat toto přizpůsobení?"
about: "Přizpůsobení webu vám umožní si nastavit vlastní CSS stylesheet a vlastní nadpisy na webu. Vyberte si z nabídky nebo vložte vlastní přizpůsobení a můžete začít editovat." about: "Přizpůsobení webu vám umožní si nastavit vlastní CSS stylesheet a vlastní nadpisy na webu. Vyberte si z nabídky nebo vložte vlastní přizpůsobení a můžete začít editovat."
email_logs: email:
title: "Záznamy o emailech" title: "Záznamy o emailech"
sent_at: "Odesláno" sent_at: "Odesláno"
email_type: "Typ emailu" email_type: "Typ emailu"

View File

@ -734,7 +734,7 @@ da:
delete: "Delete" delete: "Delete"
delete_confirm: "Delete this customization?" delete_confirm: "Delete this customization?"
email_logs: email:
title: "Email" title: "Email"
sent_at: "Sent At" sent_at: "Sent At"
email_type: "Email Type" email_type: "Email Type"

View File

@ -997,7 +997,7 @@ de:
delete_confirm: "Diese Anpassung löschen?" delete_confirm: "Diese Anpassung löschen?"
about: "Seite personalisieren erlaubt dir das Anpassen der Stilvorlagen und des Kopfbereich der Seite. Wähle oder füge eine Anpassung hinzu um mit dem Editieren zu beginnen." about: "Seite personalisieren erlaubt dir das Anpassen der Stilvorlagen und des Kopfbereich der Seite. Wähle oder füge eine Anpassung hinzu um mit dem Editieren zu beginnen."
email_logs: email:
title: "Mailprotokoll" title: "Mailprotokoll"
sent_at: "Gesendet am" sent_at: "Gesendet am"
email_type: "Mailtyp" email_type: "Mailtyp"

View File

@ -1048,14 +1048,23 @@ en:
delete_confirm: "Delete this customization?" delete_confirm: "Delete this customization?"
about: "Site Customization allow you to modify stylesheets and headers on the site. Choose or add one to start editing." about: "Site Customization allow you to modify stylesheets and headers on the site. Choose or add one to start editing."
email_logs: email:
title: "Email" title: "Email"
settings: "Settings"
logs: "Logs"
sent_at: "Sent At" sent_at: "Sent At"
email_type: "Email Type" email_type: "Email Type"
to_address: "To Address" to_address: "To Address"
test_email_address: "email address to test" test_email_address: "email address to test"
send_test: "send test email" send_test: "send test email"
sent_test: "sent!" sent_test: "sent!"
delivery_method: "Delivery Method"
preview_digest: "Preview Digest"
refresh: "Refresh"
format: "Format"
html: "html"
text: "text"
last_seen_user: "Last Seen User:"
impersonate: impersonate:
title: "Impersonate User" title: "Impersonate User"

View File

@ -726,7 +726,7 @@ es:
delete: "Delete" delete: "Delete"
delete_confirm: "Delete this customization?" delete_confirm: "Delete this customization?"
email_logs: email:
title: "Email" title: "Email"
sent_at: "Sent At" sent_at: "Sent At"
email_type: "Email Type" email_type: "Email Type"

View File

@ -994,7 +994,7 @@ fr:
delete_confirm: "Supprimer cette personnalisation" delete_confirm: "Supprimer cette personnalisation"
about: "Vous pouvez modifier les feuillets de styles et en-têtes de votre site. Choisissez ou ajouter un style pour commencer l'édition." about: "Vous pouvez modifier les feuillets de styles et en-têtes de votre site. Choisissez ou ajouter un style pour commencer l'édition."
email_logs: email:
title: "Historique des mails" title: "Historique des mails"
sent_at: "Envoyer à" sent_at: "Envoyer à"
email_type: "Type d'email" email_type: "Type d'email"

View File

@ -668,7 +668,7 @@ id:
delete: "Delete" delete: "Delete"
delete_confirm: "Delete this customization?" delete_confirm: "Delete this customization?"
email_logs: email:
title: "Email" title: "Email"
sent_at: "Sent At" sent_at: "Sent At"
email_type: "Email Type" email_type: "Email Type"

View File

@ -977,7 +977,7 @@ it:
delete_confirm: "Elimina questa personalizzazione?" delete_confirm: "Elimina questa personalizzazione?"
about: "La Personalizzazione del Sito di permette di modificare i fogli di stile e le testate del sito." about: "La Personalizzazione del Sito di permette di modificare i fogli di stile e le testate del sito."
email_logs: email:
title: "Log Email" title: "Log Email"
sent_at: "Visto il" sent_at: "Visto il"
email_type: "Tipo Email" email_type: "Tipo Email"

View File

@ -1051,7 +1051,7 @@ nl:
delete_confirm: Verwijder deze aanpassing? delete_confirm: Verwijder deze aanpassing?
about: Met aanpassingen aan de site kun je stylesheets en headers wijzigen. Kies of voeg een toe om te beginnen. about: Met aanpassingen aan de site kun je stylesheets en headers wijzigen. Kies of voeg een toe om te beginnen.
email_logs: email:
title: E-mail title: E-mail
sent_at: Verzonden op sent_at: Verzonden op
email_type: E-mailtype email_type: E-mailtype

View File

@ -935,7 +935,7 @@ pseudo:
delete_confirm: '[[ Ďéłéťé ťĥíš čůšťóɱížáťíóɳ? ]]' delete_confirm: '[[ Ďéłéťé ťĥíš čůšťóɱížáťíóɳ? ]]'
about: '[[ Šíťé Čůšťóɱížáťíóɳ áłłóŵ ýóů ťó ɱóďíƒý šťýłéšĥééťš áɳď ĥéáďéřš about: '[[ Šíťé Čůšťóɱížáťíóɳ áłłóŵ ýóů ťó ɱóďíƒý šťýłéšĥééťš áɳď ĥéáďéřš
óɳ ťĥé šíťé. Čĥóóšé óř áďď óɳé ťó šťářť éďíťíɳǧ. ]]' óɳ ťĥé šíťé. Čĥóóšé óř áďď óɳé ťó šťářť éďíťíɳǧ. ]]'
email_logs: email:
title: '[[ Éɱáíł Łóǧš ]]' title: '[[ Éɱáíł Łóǧš ]]'
sent_at: '[[ Šéɳť Áť ]]' sent_at: '[[ Šéɳť Áť ]]'
email_type: '[[ Éɱáíł Ťýƿé ]]' email_type: '[[ Éɱáíł Ťýƿé ]]'

View File

@ -624,7 +624,7 @@ pt:
delete: "Apagar" delete: "Apagar"
delete_confirm: "Apagar esta personalização?" delete_confirm: "Apagar esta personalização?"
email_logs: email:
title: "Email" title: "Email"
sent_at: "Enviado a" sent_at: "Enviado a"
email_type: "Tipo de Email" email_type: "Tipo de Email"

View File

@ -848,7 +848,7 @@ sv:
delete: "Radera" delete: "Radera"
delete_confirm: "Radera denna anpassning?" delete_confirm: "Radera denna anpassning?"
email_logs: email:
title: "E-postloggar" title: "E-postloggar"
sent_at: "Skickat" sent_at: "Skickat"
email_type: "E-posttyp" email_type: "E-posttyp"

View File

@ -977,7 +977,7 @@ zh_CN:
delete_confirm: "删除本定制内容?" delete_confirm: "删除本定制内容?"
about: "站点定制允许你修改样式表和站点头部。选择或者添加一个来开始编辑。" about: "站点定制允许你修改样式表和站点头部。选择或者添加一个来开始编辑。"
email_logs: email:
title: "电子邮件" title: "电子邮件"
sent_at: "发送时间" sent_at: "发送时间"
email_type: "邮件类型" email_type: "邮件类型"

View File

@ -977,7 +977,7 @@ zh_TW:
delete_confirm: "刪除本定制內容?" delete_confirm: "刪除本定制內容?"
about: "站點定制允許你修改樣式表和站點頭部。選擇或者添加一個來開始編輯。" about: "站點定制允許你修改樣式表和站點頭部。選擇或者添加一個來開始編輯。"
email_logs: email:
title: "電子郵件" title: "電子郵件"
sent_at: "發送時間" sent_at: "發送時間"
email_type: "郵件類型" email_type: "郵件類型"

View File

@ -930,10 +930,11 @@ en:
why: "Here's a brief summary of what happened on %{site_link} since we last saw you on %{last_seen_at}." why: "Here's a brief summary of what happened on %{site_link} since we last saw you on %{last_seen_at}."
subject_template: "[%{site_name}] Forum Activity for %{date}" subject_template: "[%{site_name}] Forum Activity for %{date}"
new_activity: "New activity on your topics and posts:" new_activity: "New activity on your topics and posts:"
new_topics: "New topics:" top_topics: "Content you might be interested in:"
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nIf you'd like to turn it off or change your email preferences, %{unsubscribe_link}." unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nIf you'd like to turn it off or change your email preferences, %{unsubscribe_link}."
click_here: "click here" click_here: "click here"
from: "%{site_name} digest" from: "%{site_name} digest"
read_more: "Read More"
private_message: private_message:
subject_template: "[%{site_name}] %{subject_prefix}%{topic_title}" subject_template: "[%{site_name}] %{subject_prefix}%{topic_title}"

View File

@ -57,11 +57,15 @@ Discourse::Application.routes.draw do
end end
resources :impersonate, constraints: AdminConstraint.new resources :impersonate, constraints: AdminConstraint.new
resources :email_logs do
resources :email do
collection do collection do
post 'test' post 'test'
get 'logs'
get 'preview-digest' => 'email#preview_digest'
end end
end end
get 'customize' => 'site_customizations#index', constraints: AdminConstraint.new get 'customize' => 'site_customizations#index', constraints: AdminConstraint.new
get 'flags' => 'flags#index' get 'flags' => 'flags#index'
get 'flags/:filter' => 'flags#index' get 'flags/:filter' => 'flags#index'

23
lib/email_renderer.rb Normal file
View File

@ -0,0 +1,23 @@
require_dependency 'email_styles'
class EmailRenderer
def initialize(message)
@message = message
end
def text
@text ||= @message.body.to_s.force_encoding('UTF-8')
end
def html
formatted_body = EmailStyles.new(PrettyText.cook(text, environment: 'email')).format
ActionView::Base.new(Rails.configuration.paths["app/views"]).render(
template: 'email/template',
format: :html,
locals: { html_body: formatted_body }
)
end
end

View File

@ -4,8 +4,10 @@
# reason. For example, emailing a user too frequently. A nil to address is also considered # reason. For example, emailing a user too frequently. A nil to address is also considered
# "do nothing" # "do nothing"
# #
# It also adds an HTML part for the plain text body using markdown # It also adds an HTML part for the plain text body
# #
require_dependency 'email_renderer'
class EmailSender class EmailSender
def initialize(message, email_type, user=nil) def initialize(message, email_type, user=nil)
@ -20,15 +22,13 @@ class EmailSender
return if @message.body.blank? return if @message.body.blank?
@message.charset = 'UTF-8' @message.charset = 'UTF-8'
plain_body = @message.body.to_s.force_encoding('UTF-8') renderer = EmailRenderer.new(@message)
@message.html_part = Mail::Part.new do @message.html_part = Mail::Part.new do
content_type 'text/html; charset=UTF-8' content_type 'text/html; charset=UTF-8'
body PrettyText.cook(plain_body, environment: 'email') body renderer.html
end end
@message.text_part.content_type = 'text/plain; charset=UTF-8' @message.text_part.content_type = 'text/plain; charset=UTF-8'
@message.deliver @message.deliver
to_address = @message.to to_address = @message.to

42
lib/email_styles.rb Normal file
View File

@ -0,0 +1,42 @@
#
# HTML emails don't support CSS, so we can use nokogiri to inline attributes based on
# matchers.
#
class EmailStyles
def initialize(html)
@html = html
end
def format
fragment = Nokogiri::HTML.fragment(@html)
fragment.css('h3').each do |h3|
h3['style'] = 'margin-bottom: 20px; background-color: #eee; padding: 10px; border: 1px solid #ddd;'
end
fragment.css('hr').each do |hr|
hr['style'] = 'background-color: #ddd; height: 1px; border: 1px;'
end
fragment.css('a').each do |a|
a['style'] = 'text-decoration: none; font-weight: bold; font-size: 15px; color: #006699;'
end
fragment.css('ul').each do |ul|
ul['style'] = 'margin: 0 0 0 10px; padding: 0 0 0 20px;'
end
fragment.css('li').each do |li|
li['style'] = 'padding-bottom: 10px'
end
fragment.css('pre').each do |pre|
pre.replace(pre.text)
end
fragment.to_html
end
end

View File

@ -2,11 +2,13 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
attr_reader :excerpt attr_reader :excerpt
def initialize(length,options) def initialize(length, options=nil)
@length = length @length = length
@excerpt = "" @excerpt = ""
@current_length = 0 @current_length = 0
options || {}
@strip_links = options[:strip_links] == true @strip_links = options[:strip_links] == true
@text_entities = options[:text_entities] == true
end end
def self.get_excerpt(html, length, options) def self.get_excerpt(html, length, options)
@ -63,7 +65,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
if count_it && @current_length + string.length > @length if count_it && @current_length + string.length > @length
length = [0, @length - @current_length - 1].max length = [0, @length - @current_length - 1].max
@excerpt << encode.call(string[0..length]) if truncate @excerpt << encode.call(string[0..length]) if truncate
@excerpt << "&hellip;" @excerpt << (@text_entities ? "..." : "&hellip;")
@excerpt << "</a>" if @in_a @excerpt << "</a>" if @in_a
throw :done throw :done
end end

View File

@ -78,9 +78,7 @@ describe EmailSender do
end end
it 'converts the html part to html' do it 'converts the html part to html' do
expect(message.html_part.body.to_s).to eq( expect(message.html_part.body.to_s).to match("<p><strong>hello</strong></p>")
"<p><strong>hello</strong></p>"
)
end end
end end
end end

View File

@ -0,0 +1,40 @@
require 'spec_helper'
require 'email'
describe EmailStyles do
def style_exists(html, css_rule)
fragment = Nokogiri::HTML.fragment(EmailStyles.new(html).format)
element = fragment.at(css_rule)
expect(element["style"]).not_to be_blank
end
it "returns blank from an empty string" do
EmailStyles.new("").format.should be_blank
end
it "attaches a style to h3 tags" do
style_exists("<h3>hello</h3>", "h3")
end
it "attaches a style to hr tags" do
style_exists("hello<hr>", "hr")
end
it "attaches a style to a tags" do
style_exists("<a href='#'>wat</a>", "a")
end
it "attaches a style to ul tags" do
style_exists("<ul><li>hello</li></ul>", "ul")
end
it "attaches a style to li tags" do
style_exists("<ul><li>hello</li></ul>", "li")
end
it "removes pre tags but keeps their contents" do
expect(EmailStyles.new("<pre>hello</pre>").format).to eq("hello")
end
end

View File

@ -146,6 +146,10 @@ test
PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>",3).should == "<a href='http://cnn.com'>cnn</a>" PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>",3).should == "<a href='http://cnn.com'>cnn</a>"
end end
it "uses an ellipsis instead of html entities if provided with the option" do
PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>", 2, text_entities: true).should == "<a href='http://cnn.com'>cn...</a>"
end
it "should truncate links" do it "should truncate links" do
PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>",2).should == "<a href='http://cnn.com'>cn&hellip;</a>" PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>",2).should == "<a href='http://cnn.com'>cn&hellip;</a>"
end end

View File

@ -0,0 +1,53 @@
require 'spec_helper'
describe Admin::EmailController do
it "is a subclass of AdminController" do
(Admin::EmailController < Admin::AdminController).should be_true
end
let!(:user) { log_in(:admin) }
context '.index' do
before do
xhr :get, :index
end
subject { response }
it { should be_success }
end
context '.logs' do
before do
xhr :get, :logs
end
subject { response }
it { should be_success }
end
context '.test' do
it 'raises an error without the email parameter' do
lambda { xhr :post, :test }.should raise_error(ActionController::ParameterMissing)
end
context 'with an email address' do
it 'enqueues a test email job' do
Jobs.expects(:enqueue).with(:test_email, to_address: 'eviltrout@test.domain')
xhr :post, :test, email_address: 'eviltrout@test.domain'
end
end
end
context '.preview_digest' do
it 'raises an error without the last_seen_at parameter' do
lambda { xhr :get, :preview_digest }.should raise_error(ActionController::ParameterMissing)
end
it "previews the digest" do
xhr :get, :preview_digest, last_seen_at: 1.week.ago
expect(response).to be_success
end
end
end

View File

@ -1,37 +0,0 @@
require 'spec_helper'
describe Admin::EmailLogsController do
it "is a subclass of AdminController" do
(Admin::EmailLogsController < Admin::AdminController).should be_true
end
let!(:user) { log_in(:admin) }
context '.index' do
before do
xhr :get, :index
end
subject { response }
it { should be_success }
end
context '.test' do
it 'raises an error without the email parameter' do
lambda { xhr :post, :test }.should raise_error(Discourse::InvalidParameters)
end
context 'with an email address' do
it 'enqueues a test email job' do
Jobs.expects(:enqueue).with(:test_email, to_address: 'eviltrout@test.domain')
xhr :post, :test, email_address: 'eviltrout@test.domain'
end
end
end
end

View File

@ -1,82 +0,0 @@
require 'spec_helper'
describe ExcerptController do
describe 'show' do
it 'raises an error without the url param' do
lambda { xhr :get, :show }.should raise_error(Discourse::InvalidParameters)
end
it 'returns 404 with a non-existant url' do
xhr :get, :show, url: 'http://madeup.com/url'
response.status.should == 404
end
it 'returns 404 from an invalid url' do
xhr :get, :show, url: 'asdfasdf'
response.status.should == 404
end
describe 'user excerpt' do
before do
@user = Fabricate(:user)
@url = "http://test.host/users/#{@user.username}"
xhr :get, :show, url: @url
end
it 'returns a valid status' do
response.should be_success
end
it 'returns an excerpt type for the forum topic' do
parsed = JSON.parse(response.body)
parsed['type'].should == 'User'
end
end
describe 'forum topic excerpt' do
before do
@post = Fabricate(:post)
@url = "http://test.host#{@post.topic.relative_url}"
xhr :get, :show, url: @url
end
it 'returns a valid status' do
response.should be_success
end
it 'returns an excerpt type for the forum topic' do
parsed = JSON.parse(response.body)
parsed['type'].should == 'Post'
end
end
describe 'post excerpt' do
before do
@post = Fabricate(:post)
@url = "http://test.host#{@post.topic.relative_url}/1"
xhr :get, :show, url: @url
end
it 'returns a valid status' do
response.should be_success
end
it 'returns an excerpt type for the forum topic' do
parsed = JSON.parse(response.body)
parsed['type'].should == 'Post'
end
end
end
end

View File

@ -31,7 +31,7 @@ describe UserNotifications do
context "with new topics" do context "with new topics" do
before do before do
Topic.expects(:new_topics).returns([Fabricate(:topic, user: Fabricate(:coding_horror))]) Topic.expects(:for_digest).returns([Fabricate(:topic, user: Fabricate(:coding_horror))])
end end
its(:to) { should == [user.email] } its(:to) { should == [user.email] }