mirror of
https://github.com/laurent22/joplin.git
synced 2025-04-01 21:24:45 +02:00
Server: View sent emails from admin dashboard
This commit is contained in:
parent
23d9ba7bf1
commit
f5f7981dba
packages/server
public/css
src
migrations
models
routes
services
utils
views
@ -3,7 +3,16 @@
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
div.main-container,
|
||||
div.navbar-container {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
div.navbar-container {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
input.form-control {
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
// Email recipient_id was incorrectly set to "0" by default. This migration set
|
||||
// it to an empty string by default, and update all rows that have "0" as
|
||||
// recipient_id.
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
|
||||
table.string('recipient_id', 32).defaultTo('').notNullable().alter();
|
||||
});
|
||||
|
||||
await db('emails').update({ recipient_id: '' }).where('recipient_id', '=', '0');
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('emails', (table: Knex.CreateTableBuilder) => {
|
||||
table.string('recipient_id', 32).defaultTo(0).notNullable().alter();
|
||||
});
|
||||
}
|
@ -10,6 +10,7 @@ import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/pe
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import dbuuid from '../utils/dbuuid';
|
||||
import { defaultPagination, PaginatedResults, Pagination } from './utils/pagination';
|
||||
import { unique } from '../utils/array';
|
||||
|
||||
const logger = Logger.create('BaseModel');
|
||||
|
||||
@ -339,6 +340,7 @@ export default abstract class BaseModel<T> {
|
||||
|
||||
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
|
||||
if (!ids.length) return [];
|
||||
ids = unique(ids);
|
||||
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
|
||||
}
|
||||
|
||||
|
31
packages/server/src/models/utils/email.ts
Normal file
31
packages/server/src/models/utils/email.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import config from '../../config';
|
||||
import { EmailSender } from '../../services/database/types';
|
||||
|
||||
interface Participant {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const senders_: Record<number, Participant> = {};
|
||||
|
||||
export const senderInfo = (senderId: EmailSender): Participant => {
|
||||
if (!senders_[senderId]) {
|
||||
if (senderId === EmailSender.NoReply) {
|
||||
senders_[senderId] = {
|
||||
name: config().mailer.noReplyName,
|
||||
email: config().mailer.noReplyEmail,
|
||||
};
|
||||
} else if (senderId === EmailSender.Support) {
|
||||
senders_[senderId] = {
|
||||
name: config().supportName,
|
||||
email: config().supportEmail,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Invalid sender ID: ${senderId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return senders_[senderId];
|
||||
};
|
136
packages/server/src/routes/admin/emails.ts
Normal file
136
packages/server/src/routes/admin/emails.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { SubPath } from '../../utils/routeUtils';
|
||||
import Router from '../../utils/Router';
|
||||
import { RouteType } from '../../utils/types';
|
||||
import { AppContext } from '../../utils/types';
|
||||
import defaultView from '../../utils/defaultView';
|
||||
import { makeTablePagination, makeTableView, Row, Table } from '../../utils/views/table';
|
||||
import { PaginationOrderDir } from '../../models/utils/pagination';
|
||||
import { formatDateTime } from '../../utils/time';
|
||||
import { adminEmailsUrl, adminEmailUrl, adminUserUrl } from '../../utils/urlUtils';
|
||||
import { createCsrfTag } from '../../utils/csrf';
|
||||
import { senderInfo } from '../../models/utils/email';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { View } from '../../services/MustacheService';
|
||||
import { markdownBodyToHtml } from '../../services/email/utils';
|
||||
|
||||
const router: Router = new Router(RouteType.Web);
|
||||
|
||||
router.get('admin/emails', async (_path: SubPath, ctx: AppContext) => {
|
||||
const models = ctx.joplin.models;
|
||||
const pagination = makeTablePagination(ctx.query, 'created_time', PaginationOrderDir.DESC);
|
||||
const page = await models.email().allPaginated(pagination);
|
||||
const users = await models.user().loadByIds(page.items.map(e => e.recipient_name));
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: adminEmailsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
pageCount: page.page_count,
|
||||
pagination,
|
||||
headers: [
|
||||
{
|
||||
name: 'id',
|
||||
label: 'ID',
|
||||
},
|
||||
{
|
||||
name: 'sender_id',
|
||||
label: 'From',
|
||||
},
|
||||
{
|
||||
name: 'recipient_name',
|
||||
label: 'To',
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
label: 'User',
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
label: 'Subject',
|
||||
},
|
||||
{
|
||||
name: 'created_time',
|
||||
label: 'Created',
|
||||
},
|
||||
{
|
||||
name: 'sent_time',
|
||||
label: 'Sent',
|
||||
},
|
||||
{
|
||||
name: 'error',
|
||||
label: 'Error',
|
||||
},
|
||||
],
|
||||
rows: page.items.map(d => {
|
||||
const sender = senderInfo(d.sender_id);
|
||||
const senderName = sender.name || sender.email || `Sender ${d.sender_id.toString()}`;
|
||||
|
||||
let error = '';
|
||||
if (d.sent_time && !d.sent_success) {
|
||||
error = d.error ? d.error : '(Unspecified error)';
|
||||
}
|
||||
|
||||
const row: Row = [
|
||||
{
|
||||
value: d.id.toString(),
|
||||
},
|
||||
{
|
||||
value: senderName,
|
||||
url: sender.email ? `mailto:${escape(sender.email)}` : '',
|
||||
},
|
||||
{
|
||||
value: d.recipient_name || d.recipient_email,
|
||||
url: `mailto:${escape(d.recipient_email)}`,
|
||||
},
|
||||
{
|
||||
value: d.recipient_id ? (users.find(u => u.id === d.recipient_id)?.email || '(not set)') : '-',
|
||||
url: d.recipient_id ? adminUserUrl(d.recipient_id) : '',
|
||||
},
|
||||
{
|
||||
value: d.subject,
|
||||
url: adminEmailUrl(d.id),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(d.created_time),
|
||||
},
|
||||
{
|
||||
value: formatDateTime(d.sent_time),
|
||||
},
|
||||
{
|
||||
value: error,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
|
||||
const view: View = {
|
||||
...defaultView('admin/emails', _('Emails')),
|
||||
content: {
|
||||
emailTable: makeTableView(table),
|
||||
csrfTag: await createCsrfTag(ctx),
|
||||
},
|
||||
};
|
||||
|
||||
return view;
|
||||
});
|
||||
|
||||
router.get('admin/emails/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
const models = ctx.joplin.models;
|
||||
|
||||
const email = await models.email().load(path.id);
|
||||
|
||||
const view: View = {
|
||||
...defaultView('admin/email', _('Email')),
|
||||
content: {
|
||||
email,
|
||||
sender: senderInfo(email.sender_id),
|
||||
bodyHtml: markdownBodyToHtml(email.body),
|
||||
},
|
||||
};
|
||||
|
||||
return view;
|
||||
});
|
||||
|
||||
export default router;
|
@ -23,8 +23,6 @@ router.get('admin/user_deletions', async (_path: SubPath, ctx: AppContext) => {
|
||||
const page = await ctx.joplin.models.userDeletion().allPaginated(pagination);
|
||||
const users = await ctx.joplin.models.user().loadByIds(page.items.map(d => d.user_id), { fields: ['id', 'email'] });
|
||||
|
||||
console.info(page);
|
||||
|
||||
const table: Table = {
|
||||
baseUrl: adminUserDeletionsUrl(),
|
||||
requestQuery: ctx.query,
|
||||
|
@ -13,6 +13,7 @@ import apiShareUsers from './api/share_users';
|
||||
import apiUsers from './api/users';
|
||||
|
||||
import adminDashboard from './admin/dashboard';
|
||||
import adminEmails from './admin/emails';
|
||||
import adminTasks from './admin/tasks';
|
||||
import adminUserDeletions from './admin/user_deletions';
|
||||
import adminUsers from './admin/users';
|
||||
@ -49,6 +50,7 @@ const routes: Routers = {
|
||||
'api/users': apiUsers,
|
||||
|
||||
'admin/dashboard': adminDashboard,
|
||||
'admin/emails': adminEmails,
|
||||
'admin/tasks': adminTasks,
|
||||
'admin/user_deletions': adminUserDeletions,
|
||||
'admin/users': adminUsers,
|
||||
|
@ -8,14 +8,10 @@ import { errorToString } from '../utils/errors';
|
||||
import EmailModel from '../models/EmailModel';
|
||||
import { markdownBodyToHtml, markdownBodyToPlainText } from './email/utils';
|
||||
import { MailerSecurity } from '../env';
|
||||
import { senderInfo } from '../models/utils/email';
|
||||
|
||||
const logger = Logger.create('EmailService');
|
||||
|
||||
interface Participant {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default class EmailService extends BaseService {
|
||||
|
||||
private transport_: any;
|
||||
@ -23,7 +19,7 @@ export default class EmailService extends BaseService {
|
||||
private async transport(): Promise<Mail> {
|
||||
if (!this.transport_) {
|
||||
try {
|
||||
if (!this.senderInfo(EmailSender.NoReply).email) {
|
||||
if (!senderInfo(EmailSender.NoReply).email) {
|
||||
throw new Error('No-reply email must be set for email service to work (Set env variable MAILER_NOREPLY_EMAIL)');
|
||||
}
|
||||
|
||||
@ -58,24 +54,6 @@ export default class EmailService extends BaseService {
|
||||
return this.transport_;
|
||||
}
|
||||
|
||||
private senderInfo(senderId: EmailSender): Participant {
|
||||
if (senderId === EmailSender.NoReply) {
|
||||
return {
|
||||
name: this.config.mailer.noReplyName,
|
||||
email: this.config.mailer.noReplyEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (senderId === EmailSender.Support) {
|
||||
return {
|
||||
name: this.config.supportName,
|
||||
email: this.config.supportEmail,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Invalid sender ID: ${senderId}`);
|
||||
}
|
||||
|
||||
private escapeEmailField(f: string): string {
|
||||
return f.replace(/[\n\r"<>]/g, '');
|
||||
}
|
||||
@ -99,7 +77,7 @@ export default class EmailService extends BaseService {
|
||||
const transport = await this.transport();
|
||||
|
||||
for (const email of emails) {
|
||||
const sender = this.senderInfo(email.sender_id);
|
||||
const sender = senderInfo(email.sender_id);
|
||||
|
||||
const mailOptions: Mail.Options = {
|
||||
from: this.formatNameAndEmail(sender.email, sender.name),
|
||||
|
@ -9,7 +9,7 @@ import { makeUrl, UrlType } from '../utils/routeUtils';
|
||||
import MarkdownIt = require('markdown-it');
|
||||
import { headerAnchor } from '@joplin/renderer';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { adminDashboardUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
|
||||
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
|
||||
import { URL } from 'url';
|
||||
|
||||
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
|
||||
@ -151,6 +151,10 @@ export default class MustacheService {
|
||||
title: _('Tasks'),
|
||||
url: adminTasksUrl(),
|
||||
},
|
||||
{
|
||||
title: _('Emails'),
|
||||
url: adminEmailsUrl(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -85,3 +85,11 @@ export function adminUserUrl(userId: string) {
|
||||
export function adminTasksUrl() {
|
||||
return `${config().adminBaseUrl}/tasks`;
|
||||
}
|
||||
|
||||
export function adminEmailsUrl() {
|
||||
return `${config().adminBaseUrl}/emails`;
|
||||
}
|
||||
|
||||
export function adminEmailUrl(id: number) {
|
||||
return `${config().adminBaseUrl}/emails/${id}`;
|
||||
}
|
||||
|
15
packages/server/src/views/admin/email.mustache
Normal file
15
packages/server/src/views/admin/email.mustache
Normal file
@ -0,0 +1,15 @@
|
||||
<div class="block">
|
||||
<strong>Subject: </strong> {{email.subject}}<br/>
|
||||
<strong>From: </strong> {{sender.name}} <{{sender.email}}> (Sender ID: {{email.sender_id}})<br/>
|
||||
<strong>To: </strong> {{email.recipient_name}} <{{email.recipient_email}}>{{#email.recipient_id}} (<a href="{{{global.baseUrl}}}/admin/users/{{email.recipient_id}}">User</a>){{/email.recipient_id}}
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div class="content">
|
||||
{{{bodyHtml}}}
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<pre class="block">{{email.body}}</pre>
|
7
packages/server/src/views/admin/emails.mustache
Normal file
7
packages/server/src/views/admin/emails.mustache
Normal file
@ -0,0 +1,7 @@
|
||||
<form method='POST' action="{{postUrl}}">
|
||||
{{{csrfTag}}}
|
||||
|
||||
{{#emailTable}}
|
||||
{{>table}}
|
||||
{{/emailTable}}
|
||||
</form>
|
@ -22,7 +22,7 @@
|
||||
<body class="page-{{{pageName}}}">
|
||||
{{> navbar}}
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<div class="container main-container">
|
||||
{{> notifications}}
|
||||
|
||||
{{#global.isAdminPage}}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{{#navbar}}
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="container navbar-container">
|
||||
<div class="navbar-brand logo-container">
|
||||
<a class="navbar-item" href="{{{global.baseUrl}}}">
|
||||
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user