Adds an email widget for AnonAddy

This commit is contained in:
Alicia Sykes 2022-01-21 12:58:15 +00:00
parent b96af21bc9
commit 58a085a550
3 changed files with 457 additions and 3 deletions

View File

@ -17,6 +17,7 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
- [Crypto Price History](#crypto-token-price-history)
- [Crypto Wallet Balance](#wallet-balance)
- [Code Stats](#code-stats)
- [Email Aliases (AnonAddy)](#anonaddy)
- [Vulnerability Feed](#vulnerability-feed)
- [Exchange Rates](#exchange-rates)
- [Public Holidays](#public-holidays)
@ -379,6 +380,50 @@ Display your coding summary. [Code::Stats](https://codestats.net/) is a free and
---
### AnonAddy
[AnonAddy](https://anonaddy.com/) is a free and open source mail forwarding service. Use it to protect your real email address, by using a different alias for each of your online accounts, and have all emails land in your normal inbox(es). Supports custom domains, email replies, PGP-encryption, multiple recipients and more
This widget display email addresses / aliases from AnonAddy. Click an email address to copy to clipboard, or use the toggle switch to enable/ disable it. Shows usage stats (bandwidth, used aliases etc), as well as total messages recieved, blocked and sent. Works with both self-hosted and managed instances of AnonAddy.
<p align="center"><img width="400" src="https://i.ibb.co/ZhfyRdV/anonaddy.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`apiKey`** | `string` | Required | Your AnonAddy API Key / Personal Access Token. You can generate this under [Account Settings](https://app.anonaddy.com/settings)
**`hostname`** | `string` | _Optional_ | If your self-hosting AnonAddy, then supply the host name. By default it will use the public hosted instance
**`apiVersion`** | `string` | _Optional_ | If you're using an API version that is not version `v1`, then specify it here
**`limit`** | `number` | _Optional_ | Limit the number of emails shown per page. Defaults to `10`
**`sortBy`** | `string` | _Optional_ | Specify the sort order for email addresses. Defaults to `updated_at`. Can be either: `local_part`, `domain`, `email`, `emails_forwarded`, `emails_blocked`, `emails_replied`, `emails_sent`, `created_at`, `updated_at` or `deleted_at`. Precede with a `-` character to reverse order.
**`searchTerm`** | `string` | _Optional_ | A search term to filter results by, will search the email, description and domain
**`disableControls`** | `boolean` | _Optional_ | Prevent any changes being made to account through the widget. User will not be able to enable or disable aliases through UI when this option is set
**`hideMeta`** | `boolean` | _Optional_ | Don't show account meta info (forward/ block count, quota usage etc)
**`hideAliases`** | `boolean` | _Optional_ | Don't show email address / alias list. Will only show account meta info
##### Example
```yaml
- type: anonaddy
options:
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxx\
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
limit: 5
sortBy: created_at
disableControls: true
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🔴 Required
- **Price**: 🟠 Free for Self-Hosted / Free Plan available on managed instance or $1/month for premium
- **Host**: Self-Hosted or Managed
- **Privacy**: _See [AnonAddy Privacy Policy](https://anonaddy.com/privacy/)_
---
### Vulnerability Feed
Keep track of recent security advisories and vulnerabilities, with optional filtering by score, exploits, vendor and product. All fields are optional.
@ -1181,14 +1226,15 @@ Glances can be launched with the `glances` command. You'll need to run it in web
##### Options
All Glance's based widgets require a `hostname`
All Glance's based widgets require a `hostname`. All other parameters are optional.
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`hostname`** | `string` | Required | The URL to your Glances instance (without a trailing slash)
**`hostname`** | `string` | Required | The URL or IP + port to your Glances instance (without a trailing slash)
**`username`** | `string` | _Optional_ | If you have setup basic auth on Glances, specify username here (defaults to `glances`)
**`password`** | `string` | _Optional_ | If you have setup basic auth on Glances, specify password here. **Note**: since this password is in plaintext, it is important not to reuse it anywhere else
**`apiVersion`** | `string` | _Optional_ | Specify an API version, defaults to V `3`. Note that support for older versions is limited
**`limit`** | `number` | _Optional_ | For widgets that show a time-series chart, optionally limit the number of data points returned. A higher number will show more historical results, but will take longer to load. A value between 300 - 800 is usually optimal
##### Info
- **CORS**: 🟢 Enabled

View File

@ -0,0 +1,400 @@
<template>
<div class="anonaddy-wrapper">
<!-- Account Info -->
<div class="account-info" v-if="meta && !hideMeta">
<PercentageChart title="Mail Stats"
:values="[
{ label: 'Forwarded', size: meta.forwardCount, color: '#20e253' },
{ label: 'Blocked', size: meta.blockedCount, color: '#f80363' },
{ label: 'Replies', size: meta.repliesCount, color: '#04e4f4' },
{ label: 'Sent', size: meta.sentCount, color: '#f6f000' },
]" />
<div class="meta-item">
<span class="lbl">Bandwidth</span>
<span class="val">
{{ meta.bandwidth | formatBytes }} out of
{{ meta.bandwidthLimit !== 100000000 ? (formatBytes(meta.bandwidthLimit)) : '∞'}}
</span>
</div>
<div class="meta-item">
<span class="lbl">Active Domains</span>
<span class="val">{{ meta.activeDomains }} out of {{ meta.activeDomainsLimit }}</span>
</div>
<div class="meta-item">
<span class="lbl">Shared Domains</span>
<span class="val">{{ meta.sharedDomains }} out of {{ meta.sharedDomainsLimit || '∞'}}</span>
</div>
<div class="meta-item">
<span class="lbl">Usernames</span>
<span class="val">{{ meta.usernamesCount }} out of {{ meta.usernamesLimit || '∞'}}</span>
</div>
</div>
<!-- Email List -->
<div class="email-list" v-if="aliases && !hideAliases">
<div class="email-row" v-for="alias in aliases" :key="alias.id">
<!-- Email address and status -->
<div class="row-1">
<Toggle v-if="!disableControls" @change="toggleAlias"
:defaultState="alias.active" :id="alias.id" :hideLabels="true" />
<span v-if="disableControls"
:class="`status ${alias.active ? 'active' : 'inactive'}`"></span>
<div class="address-copy" @click="copyToClipboard(alias.fullEmail)" title="Click to Copy">
<span class="txt-email">{{ alias.email }}</span>
<span class="txt-at">@</span>
<span class="txt-domain">{{ alias.domain }}</span>
</div>
<ClipboardIcon class="copy-btn"
@click="copyToClipboard(alias.fullEmail)"
v-tooltip="tooltip('Copy alias to clipboard')"
/>
</div>
<!-- Optional description field -->
<div class="row-2" v-if="alias.description">
<span class="description">{{ alias.description }}</span>
</div>
<!-- Num emails sent + received -->
<div class="row-3">
<span class="lbl">Forwarded</span>
<span class="val">{{ alias.forwardCount }}</span>
<span class="lbl">Blocked</span>
<span class="val">{{ alias.blockedCount }}</span>
<span class="lbl">Replied</span>
<span class="val">{{ alias.repliesCount }}</span>
<span class="lbl">Sent</span>
<span class="val">{{ alias.sentCount }}</span>
</div>
<!-- Date created / updated -->
<div class="row-4">
<span class="lbl">Created</span>
<span class="val as-date">{{ alias.createdAt | formatDate }}</span>
<span class="val as-time-ago">{{ alias.createdAt | formatTimeAgo }}</span>
</div>
</div>
</div>
<!-- Pagination Page Numbers -->
<div class="pagination" v-if="numPages && !hideAliases">
<span class="page-num first" @click="goToFirst()">«</span>
<span class="page-num" v-if="paginationRange[0] !== 1" @click="goToPrevious()">...</span>
<span
v-for="pageNum in paginationRange" :key="pageNum"
@click="goToPage(pageNum)"
:class="`page-num ${pageNum === currentPage ? 'selected' : ''}`"
>{{ pageNum }}</span>
<span class="page-num" @click="goToNext()"
v-if="paginationRange[paginationRange.length - 1] < numPages">...</span>
<span class="page-num last" @click="goToLast()">»</span>
<p class="page-status">Page {{ currentPage }} of {{ numPages }}</p>
</div>
</div>
</template>
<script>
import Toggle from '@/components/FormElements/Toggle';
import PercentageChart from '@/components/Charts/PercentageChart';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, getTimeAgo, convertBytes } from '@/utils/MiscHelpers';
import ClipboardIcon from '@/assets/interface-icons/open-clipboard.svg';
export default {
mixins: [WidgetMixin],
components: {
Toggle,
PercentageChart,
ClipboardIcon,
},
data() {
return {
aliases: null,
meta: null,
numPages: null,
currentPage: 1,
};
},
computed: {
hostname() {
return this.options.hostname || widgetApiEndpoints.anonAddy;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
limit() {
return this.options.limit || '10';
},
sortBy() {
return this.options.sortBy || 'updated_at';
},
searchTerm() {
return this.options.searchTerm || '';
},
disableControls() {
return this.options.disableControls || false;
},
apiKey() {
if (!this.options.apiKey) this.error('An apiKey is required');
return this.options.apiKey;
},
hideMeta() {
return this.options.hideMeta;
},
hideAliases() {
return this.options.hideAliases;
},
endpoint() {
return `${this.hostname}/api/${this.apiVersion}/aliases?`
+ `sort=${this.sortBy}&filter[search]=${this.searchTerm}`
+ `&page[number]=${this.currentPage}&page[size]=${this.limit}`;
},
aliasCountEndpoint() {
return `${this.hostname}/api/${this.apiVersion}/aliases?filter[search]=${this.searchTerm}`;
},
accountInfoEndpoint() {
return `${this.hostname}/api/${this.apiVersion}/account-details`;
},
headers() {
return {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${this.apiKey}`,
};
},
paginationRange() {
const arrOfRange = (start, end) => Array(end - start + 1).fill().map((_, idx) => start + idx);
const maxNumbers = this.numPages > 10 ? 10 : this.numPages;
if (this.currentPage > maxNumbers) {
return arrOfRange(this.currentPage - maxNumbers, this.currentPage);
}
return arrOfRange(1, maxNumbers);
},
},
filters: {
formatDate(timestamp) {
return timestampToDate(timestamp);
},
formatTimeAgo(timestamp) {
return getTimeAgo(timestamp);
},
formatBytes(bytes) {
return convertBytes(bytes);
},
},
created() {
this.fetchAccountInfo();
},
methods: {
copyToClipboard(text) {
navigator.clipboard.writeText(text);
this.$toasted.show('Email address copied to clipboard');
},
fetchData() {
this.makeRequest(this.endpoint, this.headers).then(this.processData);
},
fetchAccountInfo() {
// Get account info
this.makeRequest(this.accountInfoEndpoint, this.headers).then(this.processAccountInfo);
// Get number of pages of results (in the most inefficient way possible...)
this.makeRequest(this.aliasCountEndpoint, this.headers).then((response) => {
this.numPages = Math.floor(response.data.length / this.limit);
});
},
processData(data) {
// this.numPages = 14; // data.meta.to;
this.currentPage = data.meta.current_page;
const aliases = [];
data.data.forEach((alias) => {
aliases.push({
id: alias.id,
active: alias.active,
domain: alias.domain,
email: alias.local_part,
recipients: alias.recipients,
description: alias.description,
forwardCount: alias.emails_forwarded,
blockedCount: alias.emails_blocked,
repliesCount: alias.emails_replied,
sentCount: alias.emails_sent,
createdAt: alias.created_at,
updatedAt: alias.updated_at,
deletedAt: alias.deleted_at,
fullEmail: alias.email,
});
});
this.aliases = aliases;
},
processAccountInfo(data) {
const res = data.data;
this.meta = {
name: data.username || res.from_name,
bandwidth: res.bandwidth,
bandwidthLimit: res.bandwidth_limit || 100000000,
activeDomains: res.active_domain_count,
activeDomainsLimit: res.active_domain_limit,
sharedDomains: res.active_shared_domain_alias_count,
sharedDomainsLimit: res.active_shared_domain_alias_limit,
usernamesCount: res.username_count,
usernamesLimit: res.username_limit,
forwardCount: res.total_emails_forwarded,
blockedCount: res.total_emails_blocked,
repliesCount: res.total_emails_replied,
sentCount: res.total_emails_sent,
};
},
toggleAlias(state, id) {
if (this.disableControls) {
this.$toasted.show('Error, controls disabled', { className: 'toast-error' });
} else {
const method = state ? 'POST' : 'DELETE';
const path = state ? 'active-aliases' : `active-aliases/${id}`;
const body = state ? { id } : {};
const endpoint = `${this.hostname}/api/${this.apiVersion}/${path}`;
this.makeRequest(endpoint, this.headers, method, body).then(() => {
const successMsg = `Alias successfully ${state ? 'enabled' : 'disabled'}`;
this.$toasted.show(successMsg, { className: 'toast-success' });
});
}
},
goToPage(page) {
this.progress.start();
this.currentPage = page;
this.fetchData();
},
goToFirst() {
this.goToPage(1);
},
goToLast() {
this.goToPage(this.numPages);
},
goToPrevious() {
if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
},
goToNext() {
if (this.currentPage < this.numPages) this.goToPage(this.currentPage + 1);
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/style-helpers.scss';
.anonaddy-wrapper {
.account-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.5rem;
.meta-item span {
font-size: 0.8rem;
margin: 0.25rem 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
&.lbl {
font-weight: bold;
margin-right: 0.25rem;
&::after { content: ':'; }
}
}
p.username {
margin: 0.25rem 0;
}
}
.email-list {
span.lbl {
&::after { content: ':'; }
}
span.val {
font-family: var(--font-monospace);
margin: 0 0.5rem 0 0.25rem;
}
.email-row {
color: var(--widget-text-color);
padding: 0.5rem 0;
.row-1 {
@extend .svg-button;
.address-copy {
cursor: copy;
display: inline;
}
span.txt-email {
font-weight: bold;
}
span.txt-at {
margin: 0 0.1rem;
opacity: var(--dimming-factor);
}
span.status {
font-size: 1.5rem;
line-height: 1rem;
margin-right: 0.25rem;
vertical-align: middle;
&.active { color: var(--success); }
&.inactive { color: var(--danger); }
}
.copy-btn {
float: right;
border: none;
color: var(--widget-text-color);
background: var(--widget-accent-color);
}
}
.row-2 {
max-width: 90%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: var(--dimming-factor);
span.description {
font-size: 0.8rem;
font-style: italic;
}
}
.row-3, .row-4 {
font-size: 0.8rem;
opacity: var(--dimming-factor);
}
.row-4 {
span.as-time-ago {
display: none;
}
}
&:hover {
.row-4 {
.as-date { display: none; }
.as-time-ago { display: inline; }
}
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
.pagination {
text-align: center;
p.page-status {
color: var(--widget-text-color);
opacity: var(--dimming-factor);
margin: 0.25rem 0;
font-size: 0.85rem;
font-family: var(--font-monospace);
}
span.page-num {
width: 1rem;
cursor: pointer;
padding: 0 0.15rem 0.1rem 0.15rem;
margin: 0;
color: var(--widget-text-color);
border-radius: 0.25rem;
border: 1px solid transparent;
display: inline-block;
&.selected {
font-weight: bold;
color: var(--widget-background-color);
background: var(--widget-text-color);
border: 1px solid var(--widget-background-color);
}
&:hover {
border: 1px solid var(--widget-text-color);
}
}
}
}
</style>

View File

@ -18,8 +18,15 @@
</div>
<!-- Widget -->
<div v-else class="widget-wrap">
<AnonAddy
v-if="widgetType === 'anonaddy'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Apod
v-if="widgetType === 'apod'"
v-else-if="widgetType === 'apod'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
@ -370,6 +377,7 @@ export default {
OpenIcon,
LoadingAnimation,
// Register widget components
AnonAddy: () => import('@/components/Widgets/AnonAddy.vue'),
Apod: () => import('@/components/Widgets/Apod.vue'),
Clock: () => import('@/components/Widgets/Clock.vue'),
CodeStats: () => import('@/components/Widgets/CodeStats.vue'),