Add mailboxes

This commit is contained in:
Alice Gaudon 2020-07-27 16:02:09 +02:00
parent aa9c5c2f53
commit b5acd3feb1
14 changed files with 710 additions and 74 deletions

View File

@ -35,6 +35,8 @@ import FixUserMainEmailRelation from "wms-core/auth/migrations/FixUserMainEmailR
import DropNameFromUsers from "wms-core/auth/migrations/DropNameFromUsers";
import MagicLink from "wms-core/auth/models/MagicLink";
import AddNameToUsers from "./migrations/AddNameToUsers";
import CreateMailTables from "./migrations/CreateMailTables";
import MailboxBackendController from "./controllers/MailboxBackendController";
export default class App extends Application {
private readonly port: number;
@ -56,6 +58,7 @@ export default class App extends Application {
FixUserMainEmailRelation,
DropNameFromUsers,
AddNameToUsers,
CreateMailTables,
];
}
@ -123,6 +126,7 @@ export default class App extends Application {
this.use(new AccountController());
this.use(new MagicLinkController(this.magicLinkWebSocketListener!))
this.use(new BackendController());
this.use(new MailboxBackendController());
this.use(new AuthController());
// Core functionality

View File

@ -8,18 +8,47 @@ import MagicLinkController from "./MagicLinkController";
import {MagicLinkActionType} from "./MagicLinkActionType";
import UserEmail from "wms-core/auth/models/UserEmail";
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "wms-core/HttpError";
import MailDomain from "../models/MailDomain";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
import MailIdentity from "../models/MailIdentity";
import UserNameComponent from "../models/UserNameComponent";
import {WhereOperator, WhereTest} from "wms-core/db/ModelQuery";
export default class AccountController extends Controller {
public getRoutesPrefix(): string {
return '/account';
}
routes(): void {
this.get('/account', this.getAccount, 'account', REQUIRE_AUTH_MIDDLEWARE);
this.get('/', this.getAccount, 'account', REQUIRE_AUTH_MIDDLEWARE);
this.post('/add-recovery-email', this.addRecoveryEmail, 'add-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/set-main-email', this.postSetMainEmail, 'set-main-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/remove-email', this.postRemoveEmail, 'remove-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/set-main-email', this.postSetMainRecoveryEmail, 'set-main-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/remove-email', this.postRemoveRecoveryEmail, 'remove-recovery-email', REQUIRE_AUTH_MIDDLEWARE);
this.post('/create-mail-identity', this.postCreateMailIdentity, 'create-mail-identity', REQUIRE_AUTH_MIDDLEWARE);
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'delete-mail-identity', REQUIRE_AUTH_MIDDLEWARE);
}
protected async getAccount(req: Request, res: Response, next: NextFunction): Promise<void> {
const user = req.models.user!;
const userMailIdentity = user.as(UserMailIdentityComponent);
res.render('account', {
emails: await req.models.user!.emails.get(),
emails: await user.emails.get(),
mailboxIdentity: await (await userMailIdentity.mainMailIdentity.get())?.toEmail(),
identities: await Promise.all((await userMailIdentity.mailIdentities.get()).map(async identity => ({
id: identity.id,
email: await identity.toEmail(),
}))),
domains: (await MailDomain.select()
.where('user_id', user.id!)
.where('user_id', null, WhereTest.EQ, WhereOperator.OR)
.sortBy('user_id', 'DESC')
.get())
.map(d => ({
value: d.id,
display: d.name,
})),
});
}
@ -53,7 +82,7 @@ export default class AccountController extends Controller {
}));
}
protected async postSetMainEmail(req: Request, res: Response): Promise<void> {
protected async postSetMainRecoveryEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
@ -69,10 +98,10 @@ export default class AccountController extends Controller {
await req.models.user!.save();
req.flash('success', 'This email was successfully set as your main address.');
res.redirect(Controller.route('account'));
res.redirectBack();
}
protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
protected async postRemoveRecoveryEmail(req: Request, res: Response): Promise<void> {
if (!req.body.id)
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
@ -87,6 +116,64 @@ export default class AccountController extends Controller {
await userEmail.delete();
req.flash('success', 'This email was successfully removed from your account.');
res.redirect(Controller.route('account'));
res.redirectBack();
}
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
const domain = await MailDomain.getById(req.body.mail_domain_id);
if (!domain) throw new NotFoundHttpError('domain', req.url);
const user = req.models.user!;
const mailIdentityComponent = user.as(UserMailIdentityComponent);
const identity = MailIdentity.create({
user_id: user.id,
name: req.body.name,
mail_domain_id: req.body.mail_domain_id,
});
// Check whether this identity can be created by this user
if (domain.isPublic()) {
await this.validate({
name: new Validator<string>().defined().equals(user.as(UserNameComponent).name),
}, req.body);
if ((await mailIdentityComponent.getPublicAddressesCount()) >= mailIdentityComponent.getMaxPublicAddressesCount()) {
req.flash('error', 'You have reached maximum public email addresses.');
res.redirectBack();
return;
}
} else {
if (!domain.canCreateAddresses(user)) {
throw new ForbiddenHttpError('domain', req.url);
}
}
// Save identity
await identity.save();
// Set main mail identity if not already set
if (!mailIdentityComponent.main_mail_identity_id) {
mailIdentityComponent.main_mail_identity_id = identity.id;
await user.save();
req.flash('info', 'Congratulations! You just created your mailbox.');
}
req.flash('success', 'Mail identity ' + (await identity.toEmail()) + ' successfully created.')
res.redirectBack();
}
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
const identity = await MailIdentity.getById(req.body.id);
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
if (identity.user_id !== req.models.user!.id) throw new ForbiddenHttpError('mail identity', req.url);
if (req.models.user!.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
req.flash('error', 'Cannot delete your mailbox identity.');
res.redirectBack();
return;
}
await identity.delete();
req.flash('success', 'Identity ' + (await identity.toEmail()) + ' successfully deleted.');
res.redirectBack();
}
}

View File

@ -6,7 +6,6 @@ import {MagicLinkActionType} from "./MagicLinkActionType";
import Controller from "wms-core/Controller";
import {BadOwnerMagicLink} from "wms-core/auth/magic_link/MagicLinkAuthController";
import UserEmail from "wms-core/auth/models/UserEmail";
import ModelFactory from "wms-core/db/ModelFactory";
export default class MagicLinkController extends _MagicLinkController {
constructor(magicLinkWebSocketListener: MagicLinkWebSocketListener) {
@ -32,7 +31,7 @@ export default class MagicLinkController extends _MagicLinkController {
break;
}
const userEmail = ModelFactory.get(UserEmail).make({
const userEmail = UserEmail.create({
user_id: user.id,
email: email,
main: false,

View File

@ -0,0 +1,176 @@
import Controller from "wms-core/Controller";
import {REQUIRE_ADMIN_MIDDLEWARE, REQUIRE_AUTH_MIDDLEWARE} from "wms-core/auth/AuthComponent";
import {Request, Response} from "express";
import User from "wms-core/auth/models/User";
import {WhereTest} from "wms-core/db/ModelQuery";
import UserNameComponent from "../models/UserNameComponent";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
import {NotFoundHttpError} from "wms-core/HttpError";
import MailDomain from "../models/MailDomain";
import BackendController from "wms-core/helpers/BackendController";
import MailIdentity from "../models/MailIdentity";
export default class MailboxBackendController extends Controller {
public constructor() {
super();
BackendController.registerMenuElement({
getLink: async () => Controller.route('backend-mailboxes'),
getDisplayString: async () => 'Mailboxes',
getDisplayIcon: async () => 'mail',
});
}
public getRoutesPrefix(): string {
return '/backend/mailboxes';
}
public routes(): void {
this.get('/', this.getMailboxesBackend, 'backend-mailboxes', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.get('/:id', this.getMailboxBackend, 'backend-mailbox', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.post('/add-domain', this.postAddDomain, 'backend-add-domain', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE)
this.post('/remove-domain', this.postRemoveDomain, 'backend-remove-domain', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE)
this.post('/:id/create-mail-identity', this.postCreateMailIdentity, 'backend-create-mail-identity', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
this.post('/delete-mail-identity', this.postDeleteMailIdentity, 'backend-delete-mail-identity', REQUIRE_AUTH_MIDDLEWARE, REQUIRE_ADMIN_MIDDLEWARE);
}
protected async getMailboxesBackend(req: Request, res: Response): Promise<void> {
const mailDomains = await MailDomain.select()
.with('owner')
.with('identities')
.get();
const users = await User.select()
.where('main_mail_identity_id', null, WhereTest.NE)
.with('mainMailIdentity')
.with('mailIdentities')
.get();
res.render('backend/mailboxes', {
domains: await Promise.all(mailDomains.map(async domain => ({
id: domain.id,
name: domain.name,
owner_name: (await domain.owner.get())?.as(UserNameComponent).name,
identity_count: (await domain.identities.get()).length,
}))),
users: [{
value: 0,
display: 'Public',
}, ...(await User.select().get()).map(u => ({
value: u.id,
display: u.name,
}))],
mailboxes: await Promise.all(users.map(async user => ({
id: user.id,
username: user.as(UserNameComponent).name,
name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail(),
identity_count: (await user.as(UserMailIdentityComponent).mailIdentities.get()).length,
domain_count: (await user.as(UserMailIdentityComponent).mailDomains.get()).length,
}))),
});
}
protected async getMailboxBackend(req: Request, res: Response): Promise<void> {
const user = await User.select()
.where('id', req.params.id)
.with('mailIdentities')
.first();
if (!user) throw new NotFoundHttpError('User', req.url);
res.render('backend/mailbox', {
mailbox: {
id: user.id,
name: await (await user.as(UserMailIdentityComponent).mainMailIdentity.get())?.toEmail() || 'Not created.',
},
domains: (await MailDomain.select().get()).map(d => ({
display: d.name,
value: d.id,
})),
identities: await Promise.all((await user.as(UserMailIdentityComponent).mailIdentities.get()).map(async i => ({
id: i.id,
email: await i.toEmail(),
}))),
});
}
protected async postAddDomain(req: Request, res: Response): Promise<void> {
const domain = MailDomain.create({
name: req.body.name,
user_id: req.body.user_id,
});
await domain.save();
req.flash('success', `Domain ${domain.name} successfully added with owner ${(await domain.owner.get())?.name}`);
res.redirectBack();
}
protected async postRemoveDomain(req: Request, res: Response): Promise<void> {
const domain = await MailDomain.select()
.where('id', req.body.id)
.with('identities')
.first();
if (!domain) throw new NotFoundHttpError('Domain', req.url);
// Don't delete that domain if it still has identities
if ((await domain.identities.get()).length > 0) {
req.flash('error', `This domain still has identities. Please remove all of these first (don't forget to rename mailboxes).`);
res.redirectBack();
return;
}
await domain.delete();
req.flash('success', `Domain ${domain.name} successfully deleted.`);
res.redirectBack();
}
protected async postCreateMailIdentity(req: Request, res: Response): Promise<void> {
const user = await User.select()
.where('id', req.params.id)
.first();
if (!user) throw new NotFoundHttpError('User', req.url);
const domain = await MailDomain.getById(req.body.mail_domain_id);
if (!domain) throw new NotFoundHttpError('domain', req.url);
const mailIdentityComponent = user.as(UserMailIdentityComponent);
const identity = MailIdentity.create({
user_id: user.id,
name: req.body.name,
mail_domain_id: req.body.mail_domain_id,
});
// Save identity
await identity.save();
// Set main mail identity if not already set
if (!mailIdentityComponent.main_mail_identity_id) {
mailIdentityComponent.main_mail_identity_id = identity.id;
await user.save();
req.flash('info', 'Mailbox created.');
}
req.flash('success', 'Mail identity ' + (await identity.toEmail()) + ' successfully created.')
res.redirectBack();
}
protected async postDeleteMailIdentity(req: Request, res: Response): Promise<void> {
const identity = await MailIdentity.select()
.where('id', req.body.id)
.with('user')
.first();
if (!identity) throw new NotFoundHttpError('Mail identity', req.url);
const user = await identity.user.get();
if (user?.as(UserMailIdentityComponent).main_mail_identity_id === identity.id) {
req.flash('error', `Cannot delete this user's mailbox identity.`);
res.redirectBack();
return;
}
await identity.delete();
req.flash('success', 'Identity ' + (await identity.toEmail()) + ' successfully deleted.');
res.redirectBack();
}
}

View File

@ -0,0 +1,49 @@
import Migration from "wms-core/db/Migration";
import {Connection} from "mysql";
import ModelFactory from "wms-core/db/ModelFactory";
import MailDomain from "../models/MailDomain";
import MailIdentity from "../models/MailIdentity";
import User from "wms-core/auth/models/User";
import UserMailIdentityComponent from "../models/UserMailIdentityComponent";
export default class CreateMailTables extends Migration {
public async install(connection: Connection): Promise<void> {
await this.query(`CREATE TABLE mail_domains
(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(252) UNIQUE NOT NULL,
user_id INT,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
)`, connection);
await this.query(`CREATE TABLE mail_identities
(
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
mail_domain_id INT NOT NULL,
name VARCHAR(64) NOT NULL,
redirects_to VARCHAR(254),
PRIMARY KEY (id),
UNIQUE (mail_domain_id, name),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (mail_domain_id) REFERENCES mail_domains (id) ON DELETE CASCADE
)`, connection);
await this.query(`ALTER TABLE users
ADD COLUMN main_mail_identity_id INT,
ADD FOREIGN KEY main_mail_identity_fk (main_mail_identity_id) REFERENCES mail_identities (id)`, connection);
}
public async rollback(connection: Connection): Promise<void> {
await this.query(`ALTER TABLE users
DROP FOREIGN KEY main_mail_identity_fk,
DROP COLUMN main_mail_identity_id`, connection);
await this.query(`DROP TABLE IF EXISTS mail_identities, mail_domains`, connection);
}
public registerModels(): void {
ModelFactory.register(MailDomain);
ModelFactory.register(MailIdentity);
ModelFactory.get(User).addComponent(UserMailIdentityComponent);
}
}

42
src/models/MailDomain.ts Normal file
View File

@ -0,0 +1,42 @@
import Model from "wms-core/db/Model";
import User from "wms-core/auth/models/User";
import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
import ModelFactory from "wms-core/db/ModelFactory";
import MailIdentity from "./MailIdentity";
export default class MailDomain extends Model {
public id?: number = undefined;
public name?: string = undefined;
public user_id?: number = undefined;
public readonly owner: OneModelRelation<MailDomain, User> = new OneModelRelation(this, ModelFactory.get(User), {
localKey: 'user_id',
foreignKey: 'id',
});
public readonly identities: ManyModelRelation<MailDomain, MailIdentity> = new ManyModelRelation(this, ModelFactory.get(MailIdentity), {
localKey: 'id',
foreignKey: 'mail_domain_id',
});
public updateWithData(data: any) {
super.updateWithData(data);
if (typeof this.user_id !== 'undefined' && this.user_id <= 0) {
this.user_id = undefined;
}
}
protected init(): void {
this.setValidation('name').defined().maxLength(252).regexp(/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/)
this.setValidation('user_id').acceptUndefined().exists(User, 'id');
}
public isPublic(): boolean {
return !this.user_id;
}
public canCreateAddresses(user: User): boolean {
return this.user_id === user.id || this.isPublic();
}
}

View File

@ -0,0 +1,38 @@
import Model, {EMAIL_REGEX} from "wms-core/db/Model";
import User from "wms-core/auth/models/User";
import MailDomain from "./MailDomain";
import {OneModelRelation} from "wms-core/db/ModelRelation";
import ModelFactory from "wms-core/db/ModelFactory";
export default class MailIdentity extends Model {
public static get table(): string {
return 'mail_identities';
}
public id?: number = undefined;
public user_id?: number = undefined;
public mail_domain_id?: number = undefined;
public name?: string = undefined;
public redirects_to?: string = undefined;
public readonly user: OneModelRelation<MailIdentity, User> = new OneModelRelation(this, ModelFactory.get(User), {
foreignKey: 'id',
localKey: 'user_id',
});
public readonly domain: OneModelRelation<MailIdentity, MailDomain> = new OneModelRelation(this, ModelFactory.get(MailDomain), {
foreignKey: 'id',
localKey: 'mail_domain_id',
});
protected init(): void {
this.setValidation('user_id').defined().exists(User, 'id');
this.setValidation('mail_domain_id').defined().exists(MailDomain, 'id');
this.setValidation('name').defined().maxLength(64).regexp(/^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+$/)
.unique(this, 'name', () => MailIdentity.select().where('mail_domain_id', this.mail_domain_id));
this.setValidation('redirects_to').acceptUndefined().maxLength(254).regexp(EMAIL_REGEX);
}
public async toEmail(): Promise<string> {
return this.name + '@' + (await this.domain.get())!.name;
}
}

View File

@ -0,0 +1,38 @@
import ModelComponent from "wms-core/db/ModelComponent";
import User from "wms-core/auth/models/User";
import {ManyModelRelation, OneModelRelation} from "wms-core/db/ModelRelation";
import MailIdentity from "./MailIdentity";
import ModelFactory from "wms-core/db/ModelFactory";
import MailDomain from "./MailDomain";
export default class UserMailIdentityComponent extends ModelComponent<User> {
public main_mail_identity_id?: number = undefined;
public readonly mailIdentities: ManyModelRelation<User, MailIdentity> = new ManyModelRelation(this._model, ModelFactory.get(MailIdentity), {
localKey: 'id',
foreignKey: 'user_id',
});
public readonly publicMailIdentities: ManyModelRelation<User, MailIdentity> = this.mailIdentities.clone()
.filter(async model => (await model.domain.get())!.isPublic());
public readonly mailDomains: ManyModelRelation<User, MailDomain> = new ManyModelRelation(this._model, ModelFactory.get(MailDomain), {
localKey: 'id',
foreignKey: 'user_id',
});
public readonly mainMailIdentity: OneModelRelation<User, MailIdentity> = new OneModelRelation(this._model, ModelFactory.get(MailIdentity), {
foreignKey: 'id',
localKey: 'main_mail_identity_id',
});
protected init(): void {
}
public getMaxPublicAddressesCount(): number {
return 1;
}
public async getPublicAddressesCount(): Promise<number> {
return (await this.publicMailIdentities.get()).length;
}
}

View File

@ -7,7 +7,6 @@ export default class UserNameComponent extends ModelComponent<User> {
public name?: string = undefined;
public init(): void {
super.init();
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
}

View File

@ -12,7 +12,6 @@ export default class UserPasswordComponent extends ModelComponent<User> {
}
public init(): void {
super.init();
this.setValidation('password').acceptUndefined().maxLength(128);
}

View File

@ -3,74 +3,136 @@
{% set title = 'ALDAP - Welcome to the toot.party auth center!' %}
{% block body %}
<div class="container">
<main class="panel">
<main class="container">
<div class="panel">
<i data-feather="user"></i>
<h1>My account</h1>
<p>Name: {{ user.name }}</p>
</div>
<section class="sub-panel">
<h2>Email addresses</h2>
<section class="panel">
<h2><i data-feather="shield"></i> Recovery email addresses</h2>
<table class="data-table">
<thead>
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for email in emails %}
{% if email.id == user.main_email_id %}
<tr>
<td>Main</td>
<td>{{ email.email }}</td>
<td></td>
</tr>
{% endif %}
{% endfor %}
{% for email in emails %}
{% if email.id != user.main_email_id %}
<tr>
<td>Secondary</td>
<td>{{ email.email }}</td>
<td class="actions">
<form action="{{ route('set-main-recovery-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="warning"
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
<i data-feather="refresh-ccw"></i> Set as main address
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
<form action="{{ route('remove-recovery-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
<i data-feather="trash"></i> Remove
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<form action="{{ route('add-recovery-email') }}" method="POST" class="sub-panel">
<h3>Add a recovery email address:</h3>
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email we can use to identify you in case you lose access to your account', 'required') }}
<button><i data-feather="plus"></i> Add recovery email</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</section>
<section class="panel">
<h2><i data-feather="mail"></i> Mailbox</h2>
<p class="center">{% if mailboxIdentity == null %}(Not created yet){% else %}{{ mailboxIdentity }}{% endif %}</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for identity in identities %}
<tr>
<th>Type</th>
<th>Address</th>
<th>Actions</th>
<td>{{ identity.id }}</td>
<td>{{ identity.email }}</td>
<td class="actions">
<form action="{{ route('delete-mail-identity') }}" method="POST">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="trash"></i> Delete
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
</thead>
{% else %}
<tr>
<td colspan="3"><i data-feather="shield-off"></i> No recovery email address.</td>
</tr>
{% endfor %}
</tbody>
</table>
<tbody>
{% for email in emails %}
{% if email.id == user.main_email_id %}
<tr>
<td>Main</td>
<td>{{ email.email }}</td>
<td></td>
</tr>
{% endif %}
{% endfor %}
{% for email in emails %}
{% if email.id != user.main_email_id %}
<tr>
<td>Secondary</td>
<td>{{ email.email }}</td>
<td class="actions">
<form action="{{ route('set-main-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<form action="{{ route('create-mail-identity') }}" method="POST" class="sub-panel">
<h3>{% if mailboxIdentity == null %}Create your mailbox{% else %}Create a new mail identity{% endif %}</h3>
<button class="warning"
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
<i data-feather="refresh-ccw"></i> Set as main address
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
<div class="inline-fields">
{{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }}
<span>@</span>
{{ macros.field(_locals, 'select', 'mail_domain_id', null, 'Choose the email domain', null, 'required', domains) }}
</div>
<div class="hint">
<i data-feather="info"></i>
If using a "public" domain, can only be set to your username.
</div>
<form action="{{ route('remove-email') }}" method="POST">
<input type="hidden" name="id" value="{{ email.id }}">
<button><i data-feather="plus"></i> Create</button>
<button class="danger"
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
<i data-feather="trash"></i> Remove
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<form action="{{ route('add-recovery-email') }}" method="POST">
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email we can use to identify you in case you lose access to your account', 'required') }}
<button type="submit">Add recovery email</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</section>
</main>
</div>
{{ macros.csrf(getCSRFToken) }}
</form>
</section>
</main>
{% endblock %}

57
views/backend/mailbox.njk Normal file
View File

@ -0,0 +1,57 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Backend' %}
{% block body %}
<div class="container">
<main class="panel">
<h1><i data-feather="mail"></i> Mailbox</h1>
<p class="center">{{ mailbox.name }}</p>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for identity in identities %}
<tr>
<td>{{ identity.id }}</td>
<td>{{ identity.email }}</td>
<td class="actions">
<form action="{{ route('backend-delete-mail-identity') }}" method="POST">
<input type="hidden" name="id" value="{{ identity.id }}">
<button class="danger" onclick="return confirm('Are you sure you want to delete {{ identity.email }}?')">
<i data-feather="trash"></i> Delete
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="{{ route('backend-create-mail-identity', id) }}" method="POST" class="sub-panel">
<h3>{% if mailboxIdentity == null %}Create a mailbox{% else %}Create a new mail identity{% endif %}</h3>
<div class="inline-fields">
{{ macros.field(_locals, 'text', 'name', user.name, 'Email name', null, 'required') }}
<span>@</span>
{{ macros.field(_locals, 'select', 'mail_domain_id', null, 'Choose the email domain', null, 'required', domains) }}
</div>
<button><i data-feather="plus"></i> Create</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</main>
</div>
{% endblock %}

View File

@ -0,0 +1,86 @@
{% extends 'layouts/base.njk' %}
{% set title = app.name + ' - Backend' %}
{% block body %}
<h1>Mailbox manager</h1>
<div class="container">
<section class="panel">
<h2>Domains</h2>
<form action="{{ route('backend-add-domain') }}" method="POST" class="sub-panel">
<h3>Add domain</h3>
{{ macros.field(_locals, 'text', 'name', null, 'Domain name', null, 'required') }}
{{ macros.field(_locals, 'select', 'user_id', undefined, 'Owner', null, 'required', users) }}
<button><i data-feather="plus"></i> Add domain</button>
{{ macros.csrf(getCSRFToken) }}
</form>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Owner</th>
<th>Identities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr>
<td>{{ domain.id }}</td>
<td>{{ domain.name }}</td>
<td>{{ domain.owner_name | default('Public') }}</td>
<td>{{ domain.identity_count }}</td>
<td class="actions">
<form action="{{ route('backend-remove-domain') }}" method="POST">
<input type="hidden" name="id" value="{{ domain.id }}">
<button class="danger" onclick="return confirm('Are you sure you want to delete {{ domain.name }}?')">
<i data-feather="trash"></i> Remove
</button>
{{ macros.csrf(getCSRFToken) }}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="panel">
<h2>Mailboxes</h2>
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>User name</th>
<th>Name</th>
<th>Identities</th>
<th>Owned domains</th>
</tr>
</thead>
<tbody>
{% for box in mailboxes %}
<tr>
<td>{{ box.id }}</td>
<td>{{ box.username }}</td>
<td><a href="{{ route('backend-mailbox', box.id) }}">{{ box.name }}</a></td>
<td>{{ box.identity_count }}</td>
<td>{{ box.domain_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
{% endblock %}

View File

@ -9891,9 +9891,9 @@ widest-line@^3.1.0:
string-width "^4.0.0"
wms-core@^0:
version "0.19.14"
resolved "https://registry.toot.party/wms-core/-/wms-core-0.19.14.tgz#9c64954f3ccfd0b1287cdbf3f5c646725db55319"
integrity sha512-fwVjNNoKO779ZCVVGinhfOwVMPpg48DoJcz+g+QLhJgqmYqrsdULT1oZiSntE+1cgII1pWouahyGRywRJyOtQA==
version "0.19.31"
resolved "https://registry.toot.party/wms-core/-/wms-core-0.19.31.tgz#6305cf0d742ac5758a11a8ad8ca5316eaf99d4e7"
integrity sha512-tbyxn84c9xEdPwsw6OySwstdjc6dSNvWpbEDY/bq3cmJ87Q3WrIEweBRTvVuaxoR0ZISyPAL9vk4ZXnB71NCTg==
dependencies:
argon2 "^0.26.2"
compression "^1.7.4"