1
0
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:
Laurent Cozic 2022-01-22 17:28:28 +00:00
parent 23d9ba7bf1
commit f5f7981dba
14 changed files with 241 additions and 31 deletions

@ -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);
}

@ -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];
};

@ -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}`;
}

@ -0,0 +1,15 @@
<div class="block">
<strong>Subject: </strong> {{email.subject}}<br/>
<strong>From: </strong> {{sender.name}} &lt;{{sender.email}}&gt; (Sender ID: {{email.sender_id}})<br/>
<strong>To: </strong> {{email.recipient_name}} &lt;{{email.recipient_email}}&gt;{{#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>

@ -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"/>