This commit is contained in:
D-Bao 2023-11-13 23:43:25 +01:00 committed by GitHub
commit c07d1cabc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 869 additions and 0 deletions

View file

@ -1,5 +1,6 @@
from .views import (
index,
dashboard_spa,
pricing,
setting,
custom_alias,

View file

@ -0,0 +1,12 @@
from flask import render_template
from flask_login import login_required
from app.dashboard.base import dashboard_bp
@dashboard_bp.route("/dashboard_spa", methods=["GET", "POST"])
@login_required
def dashboard_spa():
return render_template(
"dashboard/dashboard_spa.html"
)

538
static/js/dashboard-spa.js Normal file
View file

@ -0,0 +1,538 @@
// only allow lowercase letters, numbers, dots (.), dashes (-) and underscores (_)
// don't allow dot at the start or end or consecutive dots
const ALIAS_PREFIX_REGEX = /^(?!\.)(?!.*\.$)(?!.*\.\.)[0-9a-z-_.]+$/;
new Vue({
el: '#dashboard-app',
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
data: {
showFilter: false,
showStats: false,
mailboxes: [],
// variables for creating alias
canCreateAlias: true,
isLoading: true,
aliasPrefixInput: "",
aliasPrefixError: "",
aliasSuffixes: [],
aliasSelectedSignedSuffix: "",
aliasNoteInput: "",
defaultMailboxId: "",
// variables for aliases list
isFetchingAlias: true,
aliasesArray: [], // array of existing alias
aliasesArrayOfNextPage: [], // to know there is a next page if not empty
page: 0,
isLoadingMoreAliases: false,
searchString: "",
filter: "", // TODO add more filters and also sorting when backend API is ready
},
computed: {
isLastPage: function () {
return this.aliasesArrayOfNextPage.length === 0;
},
},
async mounted() {
if (store.get("showFilter")) {
this.showFilter = true;
}
if (store.get("showStats")) {
this.showStats = true;
}
await this.loadInitialData();
},
methods: {
// initialize mailboxes and alias options and aliases
async loadInitialData() {
this.isLoading = true;
await this.loadMailboxes();
await this.loadAliasOptions();
this.isLoading = false;
await this.loadAliases();
},
async loadMailboxes() {
try {
const res = await fetch("/api/mailboxes");
if (res.ok) {
const result = await res.json();
this.mailboxes = result.mailboxes;
this.defaultMailboxId = this.mailboxes.find((mailbox) => mailbox.default).id;
} else {
throw new Error("Could not load mailboxes");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you try refreshing the page? ", "Could not load mailboxes");
}
},
async loadAliasOptions() {
this.isLoading = true;
try {
const res = await fetch("/api/v5/alias/options");
if (res.ok) {
const aliasOptions = await res.json();
this.aliasSuffixes = aliasOptions.suffixes;
this.aliasSelectedSignedSuffix = this.aliasSuffixes[0].signed_suffix;
this.canCreateAlias = aliasOptions.can_create;
} else {
throw new Error("Could not load alias options");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you try refreshing the page? ", "Could not load alias options");
}
this.isLoading = false;
},
async loadAliases() {
this.aliasesArray = [];
this.page = 0;
this.aliasesArray = await this.fetchAlias(this.page, this.searchString);
this.aliasesArrayOfNextPage = await this.fetchAlias(this.page + 1, this.searchString);
// use jquery multiple select plugin after Vue has rendered the aliases in the DOM
this.$nextTick(() => {
$('.mailbox-select').multipleSelect();
$('.mailbox-select').removeClass('mailbox-select');
});
},
async fetchAlias(page, query) {
this.isFetchingAlias = true;
try {
const res = await fetch(`/api/v2/aliases?page_id=${page}&${this.filter}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
...(query && { body: JSON.stringify({ query }) }),
});
if (res.ok) {
const result = await res.json();
this.isFetchingAlias = false;
return result.aliases;
} else {
throw new Error("Aliases could not be loaded");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you try refreshing the page? ", "Aliases could not be loaded");
this.isFetchingAlias = false;
return [];
}
},
async toggleFilter() {
this.showFilter = !this.showFilter;
store.set('showFilter', this.showFilter);
},
async toggleStats() {
this.showStats = !this.showStats;
store.set('showStats', this.showStats);
},
async toggleAlias(alias) {
try {
const res = await fetch(`/api/aliases/${alias.id}/toggle`, {
method: "POST",
});
if (res.ok) {
const result = await res.json();
alias.enabled = result.enabled;
toastr.success(`${alias.email} is ${alias.enabled ? "enabled" : "disabled"}`);
} else {
throw new Error("Could not disable/enable alias");
}
} catch (err) {
alias.enabled = !alias.enabled;
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Could not disable/enable alias");
}
},
async handleNoteChange(alias) {
try {
const res = await fetch(`/api/aliases/${alias.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
note: alias.note,
}),
});
if (res.ok) {
toastr.success(`Note saved for ${alias.email}`);
} else {
throw new Error("Note could not be saved");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Note could not be saved");
}
},
async handleDisplayNameChange(alias) {
try {
let res = await fetch(`/api/aliases/${alias.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: alias.name,
}),
});
if (res.ok) {
toastr.success(`Display name saved for ${alias.email}`);
} else {
throw new Error("Could not save Display name");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Could not save Display name")
}
},
async handlePgpToggle(alias) {
try {
let res = await fetch(`/api/aliases/${alias.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
disable_pgp: alias.disable_pgp,
}),
});
if (res.ok) {
toastr.success(`PGP ${alias.disable_pgp ? "disabled" : "enabled"} for ${alias.email}`);
} else {
throw new Error("Could not toggle PGP")
}
} catch (err) {
alias.disable_pgp = !alias.disable_pgp;
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Could not toggle PGP");
}
},
async handlePin(alias) {
try {
let res = await fetch(`/api/aliases/${alias.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pinned: !alias.pinned,
}),
});
if (res.ok) {
alias.pinned = !alias.pinned;
if (alias.pinned) {
// make alias appear at the top of the alias list
const index = this.aliasesArray.findIndex((a) => a.id === alias.id);
this.aliasesArray.splice(index, 1);
this.aliasesArray.unshift(alias);
toastr.success(`${alias.email} is pinned`);
} else {
// unpin: make alias appear at the bottom of the alias list
const index = this.aliasesArray.findIndex((a) => a.id === alias.id);
this.aliasesArray.splice(index, 1);
this.aliasesArray.push(alias);
toastr.success(`${alias.email} is unpinned`);
}
} else {
throw new Error("Alias could not be pinned");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Alias could not be pinned");
}
},
async handleDeleteAliasClick(alias, aliasDomainTrashUrl) {
let message = `If you don't want to receive emails from this alias, you can disable it. Please note that once deleted, it <b>can't</b> be restored.`;
if (aliasDomainTrashUrl !== undefined) {
message = `If you want to stop receiving emails from this alias, you can disable it instead. When it's deleted, it's moved to the domain
<a href="${aliasDomainTrashUrl}">trash</a>`;
}
const that = this;
bootbox.dialog({
title: `Delete alias ${alias.email}?`,
message: message,
onEscape: true,
backdrop: true,
centerVertical: true,
buttons: {
// show disable button only if alias is enabled
...(alias.enabled ? {
disable: {
label: 'Disable it',
className: 'btn-primary',
callback: function () {
that.disableAlias(alias);
}
}
} : {}),
delete: {
label: "Delete it, I don't need it anymore",
className: 'btn-danger',
callback: function () {
that.deleteAlias(alias);
}
},
cancel: {
label: 'Cancel',
className: 'btn-outline-primary'
},
}
});
},
async deleteAlias(alias) {
try {
let res = await fetch(`/api/aliases/${alias.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
if (res.ok) {
toastr.success(`Alias ${alias.email} deleted`);
this.aliasesArray = this.aliasesArray.filter((a) => a.id !== alias.id);
} else {
throw new Error("Alias could not be deleted");
}
await this.loadAliasOptions();
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Alias could not be deleted");
}
},
async disableAlias(alias) {
try {
if (alias.enabled === false) {
return;
}
let res = await fetch(`/api/aliases/${alias.id}/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
alias_id: alias.id,
}),
});
if (res.ok) {
alias.enabled = false;
toastr.success(`${alias.email} is disabled`);
} else {
throw new Error("Alias could not be disabled");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Alias could not be disabled");
}
},
// merge newAliases into currentAliases. If conflict, keep the new one
mergeAliases(currentAliases, newAliases) {
// dict of aliasId and alias to speed up research
let newAliasesDict = {};
for (let i = 0; i < newAliases.length; i++) {
let alias = newAliases[i];
newAliasesDict[alias.id] = alias;
}
let ret = [];
// keep track of added aliases
let alreadyAddedId = {};
for (let i = 0; i < currentAliases.length; i++) {
let alias = currentAliases[i];
if (newAliasesDict[alias.id]) ret.push(newAliasesDict[alias.id]);
else ret.push(alias);
alreadyAddedId[alias.id] = true;
}
for (let i = 0; i < newAliases.length; i++) {
let alias = newAliases[i];
if (!alreadyAddedId[alias.id]) {
ret.push(alias);
}
}
return ret;
},
async loadMoreAliases() {
this.isLoadingMoreAliases = true;
this.page++;
// we already fetched aliases of the next page, just merge it
this.aliasesArray = this.mergeAliases(this.aliasesArray, this.aliasesArrayOfNextPage);
// fetch next page in advance to know if there is a next page
this.aliasesArrayOfNextPage = await this.fetchAlias(this.page + 1, this.searchString);
// use jquery multiple select plugin after Vue has rendered the aliases in the DOM
this.$nextTick(() => {
$('.mailbox-select').multipleSelect();
$('.mailbox-select').removeClass('mailbox-select');
});
this.isLoadingMoreAliases = false;
},
resetFilter() {
this.searchString = "";
this.filter = "";
this.loadAliases();
},
// enable or disable the 'Create' button depending on whether the alias prefix is valid or not
handleAliasPrefixInput() {
this.aliasPrefixInput = this.aliasPrefixInput.toLowerCase();
if (this.aliasPrefixInput.match(ALIAS_PREFIX_REGEX)) {
document.querySelector('.bootbox-accept').classList.remove('disabled');
this.aliasPrefixError = "";
} else {
document.querySelector('.bootbox-accept').classList.add('disabled');
this.aliasPrefixError = this.aliasPrefixInput.length > 0 ? "Only lowercase letters, numbers, dots (.), dashes (-) and underscores (_) are supported." : "";
}
},
async createCustomAlias() {
try {
const res = await fetch("/api/v3/alias/custom/new", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
alias_prefix: this.aliasPrefixInput,
signed_suffix: this.aliasSelectedSignedSuffix,
mailbox_ids: [this.defaultMailboxId],
note: this.aliasNoteInput,
}),
});
if (res.ok) {
const alias = await res.json();
this.aliasesArray.unshift(alias);
toastr.success(`Alias ${alias.email} created`);
// use jquery multiple select plugin after Vue has rendered the aliases in the DOM
this.$nextTick(() => {
$('.mailbox-select').multipleSelect();
$('.mailbox-select').removeClass('mailbox-select');
});
} else {
const error = await res.json();
toastr.error(error.error, "Alias could not be created");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Alias could not be created");
}
this.aliasPrefixInput = "";
this.aliasNoteInput = "";
await this.loadAliasOptions();
},
handleNewCustomAliasClick() {
const that = this;
bootbox.dialog({
title: "Create an alias",
message: this.$refs.createAliasModal,
size: 'large',
onEscape: true,
backdrop: true,
centerVertical: true,
onShown: function (e) {
document.getElementById('create-alias-prefix-input').focus();
if (that.aliasPrefixInput) {
that.handleAliasPrefixInput();
}
},
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-outline-primary'
},
confirm: {
label: 'Create',
className: 'btn-primary disabled',
callback: function () {
that.createCustomAlias();
}
}
}
});
},
}
});
async function handleMailboxChange(event) {
aliasId = event.target.dataset.aliasId;
aliasEmail = event.target.dataset.aliasEmail;
const selectedOptions = event.target.selectedOptions;
const mailbox_ids = Array.from(selectedOptions).map((selectedOption) => selectedOption.value);
if (mailbox_ids.length === 0) {
toastr.error("You must select at least a mailbox", "Error");
return;
}
try {
let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mailbox_ids: mailbox_ids,
}),
});
if (res.ok) {
toastr.success(`Mailbox updated for ${aliasEmail}`);
} else {
throw new Error("Mailbox could not be updated");
}
} catch (err) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Mailbox could not be updated");
}
}

View file

@ -0,0 +1,318 @@
{% extends "default.html" %}
{% set active_page = "dashboard" %}
{% block head %}
<style>
.alias-activity {
font-weight: 600;
font-size: 14px;
}
.btn-group-border-left {
border-left: 1px #fbfbfb4f solid;
}
em {
padding: 0px;
}
{# CSS to change collapse button display from https://stackoverflow.com/a/31967516/1428034 #}
[data-toggle="collapse"].collapsed .if-not-collapsed {
display: none;
}
[data-toggle="collapse"]:not(.collapsed) .if-collapsed {
display: none;
}
.alias-disabled {
opacity: 0.6;
}
</style>
{% endblock %}
{% block title %}Aliases{% endblock %}
{% block default_content %}
<div id="dashboard-app">
<!-- Controls: buttons & search -->
<div>
<div class="row mb-3">
<div class="col d-flex flex-wrap justify-content-between">
<div class="mb-1">
<div class="btn-group" role="group">
<button @click="handleNewCustomAliasClick"
class="btn btn-primary mr-2"
:disabled="isLoading || !canCreateAlias">
<i class="fa fa-plus"></i> New custom alias
</button>
</div>
</div>
<div>
<div class="btn-group">
<a @click="toggleStats()" class="btn btn-outline-secondary">
<span v-if="!showStats">
<i class="fe fe-chevrons-down"></i>
Show stats
</span>
<span v-else>
<i class="fe fe-chevrons-up"></i>
Hide stats
</span>
</a>
</div>
<div class="btn-group">
<a @click="toggleFilter()" class="btn btn-outline-secondary">
<span v-if="!showFilter">
<i class="fe fe-chevrons-down"></i>
Show filters
</span>
<span v-else>
<i class="fe fe-chevrons-up"></i>
Hide filters
</span>
</a>
</div>
</div>
</div>
</div>
<!-- TODO Global Stats -->
<div class="row" v-if="showStats"></div>
<!-- END Global Stats -->
<div class="row mb-2" v-if="showFilter" id="filter-control">
<!-- Filter Control -->
<div class="col d-flex flex-wrap">
<div class="flex-grow-0 mr-2">
<select name="filter"
v-model="filter"
@change="loadAliases"
class="form-control mr-3 shadow flex-grow-0">
<option value="">
All aliases
</option>
<option value="pinned">
Pinned aliases
</option>
<option value="enabled">
Enabled aliases
</option>
<option value="disabled">
Disabled aliases
</option>
</select>
</div>
<!-- Search -->
<div class="flex-grow-1">
<input type="search"
name="query"
v-model="searchString"
placeholder="Enter to search for alias"
class="form-control shadow mr-2"
@keyup.enter="loadAliases">
</div>
<div v-if="searchString || filter" class="ml-2">
<a @click="resetFilter()" class="btn btn-outline-secondary">Reset</a>
</div>
</div>
</div>
</div>
<!-- END Controls: buttons & search -->
<!-- Alias list -->
<div id="alias-list" class="row">
<div v-for="(alias, index) in aliasesArray"
:key="alias.id"
class="col-12 col-md-6">
<!-- TODO highlight newly created alias -->
<div class="card p-4 shadow-sm"
:class="{ 'alias-disabled': !alias.enabled }">
<div class="d-flex justify-space-between">
<div class="d-flex">
<h5 class="clipboard mb-0 text-break d-inline-block cursor"
:data-toggle="alias.enabled ? 'tooltip' : null"
title="Click to copy"
:data-clipboard-text="alias.enabled ? alias.email : null">
[[ alias.email ]]
</h5>
<span v-if="alias.pinned"
data-toggle="tooltip"
title="Click to unpin"
@click="handlePin(alias)"
class="cursor ml-2">
<i class="fa fa-thumb-tack"></i>
</span>
</div>
<div class="col text-right">
<label class="custom-switch cursor"
data-toggle="tooltip"
:title="alias.enabled ? 'Disable alias - Stop receiving emails sent to this alias' : 'Enable alias - Start receiving emails sent to this alias'">
<input type="checkbox"
class="enable-disable-alias custom-switch-input"
v-model="alias.enabled"
@change="toggleAlias(alias)">
<span class="custom-switch-indicator"></span>
</label>
</div>
</div>
<!-- Email Activity -->
<div v-if="!alias.enabled" class="small-text mb-4">
Alias is disabled, you will not receive any email from this alias.
</div>
<div v-else-if="alias.latest_activity" class="small-text mb-4">
[[ alias.latest_activity.contact.email ]]
<span v-if="alias.latest_activity.action === 'bounched'">
<i class="fa fa-exclamation-triangle mr-2"></i> Cannot be forwarded to your mailbox.
</span>
<span v-if="alias.latest_activity.action === 'block'">
<i class="fa fa-ban mr-2"></i> Blocked.
</span>
<!-- TODO improve date formatting -->
Created [[ alias.creation_date ]].
</div>
<div v-else class="small-text mb-4">
No emails received/sent in the last 14 days.
Created [[ alias.creation_date ]].
</div>
<!-- END Email Activity -->
<div class="small-text mb-4">
<label class="mb-0" :for="`note-${alias.id}`">Note</label>
<textarea v-model="alias.note" name="note" class="form-control" rows="2" placeholder="e.g. where the alias is used or why is it created" @change="handleNoteChange(alias)">[[ alias.note ]]</textarea>
</div>
<div v-if="mailboxes && mailboxes.length > 1" class="mb-4">
<label class="small-text mb-0" :for="`mailbox-${alias.id}`">Current mailbox</label>
<div class="d-flex">
<!-- not using Vue's event @change below for compatibility with jquery multipleSelect plugin -->
<select required
:id="`mailbox-${alias.id}`"
class="mailbox-select flex-grow-1"
name="mailbox"
:data-alias-id="alias.id"
:data-alias-email="alias.email"
onchange="handleMailboxChange(event)"
multiple>
<option v-for="mailbox in mailboxes"
:value="mailbox.id"
:key="mailbox.id"
:selected="alias.mailboxes.find(m => m.id === mailbox.id)">
[[ mailbox.email ]]
</option>
</select>
</div>
<!-- TODO mailbox owned by another user -->
</div>
<div class="small-text mb-4">
<label class="mb-0" :for="`alias-name-${alias.id}`">Display name</label>
<input :id="`alias-name-${alias.id}`"
type="text"
class="form-control"
name="display-name"
placeholder="e.g. John Doe"
v-model="alias.name"
@change="handleDisplayNameChange(alias)">
<div>
'From: [[ alias.name ]] &lt;[[ alias.email ]]&gt;' will be in the email header when you send an email from this alias.
</div>
</div>
<div v-if="alias.support_pgp" class="d-flex mb-2">
<label class="custom-switch cursor pl-0">
<input type="checkbox"
class="custom-switch-input"
v-model="alias.disable_pgp"
:true-value="false"
:false-value="true"
@change="handlePgpToggle(alias)">
<span class="custom-switch-indicator"></span>
</label>
<label for="`enable-disable-pgp-${alias.id}`" class="ml-2 mb-0">PGP</label>
</div>
<div class="d-flex">
<button class="btn btn-sm"
:class="[alias.pinned ? 'btn-primary' : 'btn-outline-primary']"
@click="handlePin(alias)">
<i class="fa fa-thumb-tack"></i>
<span v-if="alias.pinned">Pinned</span>
<span v-else>Pin</span>
</button>
<div class="ml-auto d-flex">
<button class="btn btn-link btn-sm">
<i class="fe fe-share-2"></i>
Transfer
</button>
<!-- TODO aliasDomainTrashUrl -->
<button class="btn btn-link text-danger btn-sm ml-1"
@click="handleDeleteAliasClick(alias)">
<i class="fa fa-trash"></i>
Delete
</button>
</div>
</div>
</div>
</div>
</div>
<!-- END Alias list -->
<div v-if="isFetchingAlias" class="row">
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
<div v-if="!isLastPage && !isFetchingAlias" class="row">
<!-- load more aliases button -->
<div class="col-12 col-md-6 mb-2">
<button class="btn btn-outline-primary btn-block"
@click="loadMoreAliases"
:disabled="isLoadingMoreAliases">
<span class="fe fe-chevron-down"></span>
Load more aliases
</button>
</div>
</div>
<!-- HTML for create alias modal -->
<div style="display: none">
<div ref="createAliasModal">
<label class="mb-0" for="create-alias-prefix-input">Alias email</label>
<div class="row mb-2">
<div class="col-sm-6 pr-sm-1" style="min-width: 4em">
<input v-model="aliasPrefixInput"
@input="handleAliasPrefixInput"
type="text"
id="create-alias-prefix-input"
name="alias-prefix"
autofocus
class="form-control"
placeholder="newsletter-123_xyz"
required>
</div>
<div class="col-sm-6 pl-sm-1">
<select class="form-control"
v-model="aliasSelectedSignedSuffix"
name="alias-suffix"
required>
<option v-for="suffix in aliasSuffixes"
:key="suffix.signed_suffix"
:value="suffix.signed_suffix">
[[ suffix.suffix ]]
</option>
</select>
</div>
</div>
<div class="row text-danger"
style="font-size: 12px"
v-if="aliasPrefixError">
<div class="col">[[ aliasPrefixError ]]</div>
</div>
<label class="w-100">
Note (optional)
<textarea v-model="aliasNoteInput" name="note" class="form-control" rows="2" placeholder="e.g. where the alias is used or why is it created"></textarea>
</label>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="/static/js/dashboard-spa.js"></script>
<script>// TODO show intro tutorial with introJs()</script>
{% endblock %}