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 Price History](#crypto-token-price-history)
- [Crypto Wallet Balance](#wallet-balance) - [Crypto Wallet Balance](#wallet-balance)
- [Code Stats](#code-stats) - [Code Stats](#code-stats)
- [Email Aliases (AnonAddy)](#anonaddy)
- [Vulnerability Feed](#vulnerability-feed) - [Vulnerability Feed](#vulnerability-feed)
- [Exchange Rates](#exchange-rates) - [Exchange Rates](#exchange-rates)
- [Public Holidays](#public-holidays) - [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 ### Vulnerability Feed
Keep track of recent security advisories and vulnerabilities, with optional filtering by score, exploits, vendor and product. All fields are optional. 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 ##### 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** **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`) **`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 **`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 **`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 ##### Info
- **CORS**: 🟢 Enabled - **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> </div>
<!-- Widget --> <!-- Widget -->
<div v-else class="widget-wrap"> <div v-else class="widget-wrap">
<AnonAddy
v-if="widgetType === 'anonaddy'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Apod <Apod
v-if="widgetType === 'apod'" v-else-if="widgetType === 'apod'"
:options="widgetOptions" :options="widgetOptions"
@loading="setLoaderState" @loading="setLoaderState"
@error="handleError" @error="handleError"
@ -370,6 +377,7 @@ export default {
OpenIcon, OpenIcon,
LoadingAnimation, LoadingAnimation,
// Register widget components // Register widget components
AnonAddy: () => import('@/components/Widgets/AnonAddy.vue'),
Apod: () => import('@/components/Widgets/Apod.vue'), Apod: () => import('@/components/Widgets/Apod.vue'),
Clock: () => import('@/components/Widgets/Clock.vue'), Clock: () => import('@/components/Widgets/Clock.vue'),
CodeStats: () => import('@/components/Widgets/CodeStats.vue'), CodeStats: () => import('@/components/Widgets/CodeStats.vue'),