mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 22:21:55 +08:00
Better HTML emails, smarter email digests, new email section in admin with digest preview
This commit is contained in:
parent
f030d9b420
commit
0b97ea6345
|
@ -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 () {
|
|
@ -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);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
|
@ -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) {
|
||||||
|
|
21
app/assets/javascripts/admin/models/email_preview.js
Normal file
21
app/assets/javascripts/admin/models/email_preview.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
17
app/assets/javascripts/admin/models/email_settings.js
Normal file
17
app/assets/javascripts/admin/models/email_settings.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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'});
|
||||||
|
}
|
||||||
|
});
|
|
@ -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'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
|
@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -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'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -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'});
|
||||||
|
|
||||||
|
|
|
@ -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'});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
11
app/assets/javascripts/admin/templates/email.js.handlebars
Normal file
11
app/assets/javascripts/admin/templates/email.js.handlebars
Normal 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}}
|
|
@ -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>
|
|
@ -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}}
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
@ -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 = "";
|
||||||
|
|
|
@ -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>
|
|
|
@ -1 +0,0 @@
|
||||||
<a class='close' href='#' {{action close target="view.parentView"}}><i class='icon-white icon-remove'></i></a>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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 {
|
||||||
|
|
37
app/controllers/admin/email_controller.rb
Normal file
37
app/controllers/admin/email_controller.rb
Normal 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
|
|
@ -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
|
|
|
@ -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
|
|
12
app/helpers/user_notifications_helper.rb
Normal file
12
app/helpers/user_notifications_helper.rb
Normal 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
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -1,11 +0,0 @@
|
||||||
module ExcerptType
|
|
||||||
|
|
||||||
def self.included(base)
|
|
||||||
base.attributes :type
|
|
||||||
end
|
|
||||||
|
|
||||||
def type
|
|
||||||
self.class.name.sub(/ExcerptSerializer/, '')
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
|
@ -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
|
|
|
@ -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
|
|
23
app/views/email/template.html.erb
Normal file
23
app/views/email/template.html.erb
Normal 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>
|
|
@ -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 %>
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -935,7 +935,7 @@ pseudo:
|
||||||
delete_confirm: '[[ Ďéłéťé ťĥíš čůšťóɱížáťíóɳ? ]]'
|
delete_confirm: '[[ Ďéłéťé ťĥíš čůšťóɱížáťíóɳ? ]]'
|
||||||
about: '[[ Šíťé Čůšťóɱížáťíóɳ áłłóŵ ýóů ťó ɱóďíƒý šťýłéšĥééťš áɳď ĥéáďéřš
|
about: '[[ Šíťé Čůšťóɱížáťíóɳ áłłóŵ ýóů ťó ɱóďíƒý šťýłéšĥééťš áɳď ĥéáďéřš
|
||||||
óɳ ťĥé šíťé. Čĥóóšé óř áďď óɳé ťó šťářť éďíťíɳǧ. ]]'
|
óɳ ťĥé šíťé. Čĥóóšé óř áďď óɳé ťó šťářť éďíťíɳǧ. ]]'
|
||||||
email_logs:
|
email:
|
||||||
title: '[[ Éɱáíł Łóǧš ]]'
|
title: '[[ Éɱáíł Łóǧš ]]'
|
||||||
sent_at: '[[ Šéɳť Áť ]]'
|
sent_at: '[[ Šéɳť Áť ]]'
|
||||||
email_type: '[[ Éɱáíł Ťýƿé ]]'
|
email_type: '[[ Éɱáíł Ťýƿé ]]'
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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: "邮件类型"
|
||||||
|
|
|
@ -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: "郵件類型"
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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
23
lib/email_renderer.rb
Normal 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
|
|
@ -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
42
lib/email_styles.rb
Normal 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
|
|
@ -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 << "…"
|
@excerpt << (@text_entities ? "..." : "…")
|
||||||
@excerpt << "</a>" if @in_a
|
@excerpt << "</a>" if @in_a
|
||||||
throw :done
|
throw :done
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
||||||
|
|
40
spec/components/email_styles_spec.rb
Normal file
40
spec/components/email_styles_spec.rb
Normal 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
|
|
@ -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…</a>"
|
PrettyText.excerpt("<a href='http://cnn.com'>cnn</a>",2).should == "<a href='http://cnn.com'>cn…</a>"
|
||||||
end
|
end
|
||||||
|
|
53
spec/controllers/admin/email_controller_spec.rb
Normal file
53
spec/controllers/admin/email_controller_spec.rb
Normal 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
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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] }
|
||||||
|
|
Loading…
Reference in New Issue
Block a user