feat: email client prototype

This commit is contained in:
2024-06-26 20:40:00 +02:00
committed by Thea Schöbl
parent e6c17c860b
commit 7de16808fc
41 changed files with 1396 additions and 213 deletions

View File

@@ -106,7 +106,9 @@
"ngx-markdown": "17.1.1",
"ngx-moment": "6.0.2",
"opening_hours": "3.8.0",
"pkijs": "3.1.0",
"pmtiles": "3.0.3",
"postal-mime": "2.2.5",
"rxjs": "7.8.1",
"semver": "7.6.0",
"swiper": "8.4.5",
@@ -139,6 +141,7 @@
"@ionic/cli": "7.2.0",
"@openstapps/prettier-config": "workspace:*",
"@openstapps/tsconfig": "workspace:*",
"@types/dompurify": "^3.0.5",
"@types/fontkit": "2.0.7",
"@types/geojson": "1.0.6",
"@types/glob": "8.1.0",
@@ -154,6 +157,7 @@
"@typescript-eslint/parser": "7.2.0",
"cordova-res": "0.15.4",
"cypress": "13.7.0",
"dompurify": "^3.1.6",
"eslint": "8.57.0",
"eslint-plugin-jsdoc": "48.2.1",
"eslint-plugin-prettier": "5.1.3",

View File

@@ -71,6 +71,7 @@ import {Capacitor} from '@capacitor/core';
import {SplashScreen} from '@capacitor/splash-screen';
import maplibregl from 'maplibre-gl';
import {Protocol} from 'pmtiles';
import {MailModule} from './modules/mail/mail.module';
registerLocaleData(localeDe);
@@ -165,6 +166,7 @@ export function createTranslateLoader(http: HttpClient) {
ProfilePageModule,
FeedbackModule,
MapModule,
MailModule,
MenuModule,
NavigationModule,
NewsModule,

View File

@@ -0,0 +1,10 @@
import {Injectable} from '@angular/core';
@Injectable({providedIn: 'root'})
export class MailAuthService {
login(username: string, password: string) {
navigator.credentials.store(new PasswordCredential({}))
}
logout() {}
}

View File

@@ -0,0 +1,51 @@
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
import {MailService} from './mail.service';
import {AsyncPipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
import {DataModule} from '../data/data.module';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {UtilModule} from 'src/app/util/util.module';
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
import {ActivatedRoute, RouterModule} from '@angular/router';
import {mergeMap, tap} from 'rxjs';
import {DomSanitizer} from '@angular/platform-browser';
import {MailPartComponent} from './parts/mail-part.component';
import {MailMetaComponent} from './mail-meta.component';
@Component({
selector: 'stapps-mail-detail',
templateUrl: 'mail-detail.html',
styleUrl: 'mail-detail.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
IonicModule,
DataModule,
IonIconModule,
UtilModule,
FormatPurePipeModule,
ParseIsoPipeModule,
RouterModule,
MailPartComponent,
MailMetaComponent,
],
})
export class MailDetailComponent {
mail = this.activatedRoute.params.pipe(
mergeMap(parameters => this.mailService.getMail(parameters.id)),
tap(console.log),
);
collapse = signal(false);
constructor(
readonly mailService: MailService,
readonly activatedRoute: ActivatedRoute,
readonly sanitizer: DomSanitizer,
) {}
todo() {
alert('TODO');
}
}

View File

@@ -0,0 +1,65 @@
<ion-header>
<ion-toolbar color="primary" mode="ios">
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title
[style.opacity]="(collapse() ? '1' : '0') + '!important'"
[style.translate]="collapse() ? '0' : '0 10px'"
>
@if (mail | async; as mail) {
{{ mail.envelope.subject }}
} @else {
<ion-skeleton-text animated style="width: 100px; height: 20px"></ion-skeleton-text>
}
</ion-title>
<ion-buttons slot="end">
<ion-button (click)="todo()">
<ion-icon slot="icon-only" name="delete"></ion-icon>
</ion-button>
<ion-button (click)="todo()">
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content parallax [scrollEvents]="true" (ionScroll)="collapse.set($any($event).detail.scrollTop > 50)">
<h1>
@if (mail | async; as mail) {
{{ mail.envelope.subject }}
} @else {
<ion-skeleton-text animated style="width: 100px; height: 20px"></ion-skeleton-text>
}
</h1>
<div class="body">
@if (mail | async; as mail) {
<ion-item lines="none">
<div slot="start" class="avatar">
@if (mail.envelope.from[0]; as from) {
<div>
{{ (from.name || from.address).charAt(0).toUpperCase() }}
</div>
}
</div>
<ion-label>
@for (from of mail.envelope.from; track from) {
<h2>{{ from.name || from.address }}</h2>
}
<p>
to
@for (to of mail.envelope.to; track to) {
{{ to.name || to.address }}
}
</p>
</ion-label>
</ion-item>
<stapps-mail-part [part]="mail.bodyStructure" [mail]="mail.seq"></stapps-mail-part>
} @else {
<ion-spinner></ion-spinner>
}
</div>
@if (mail | async; as mail) {
<stapps-mail-meta [mail]="mail"></stapps-mail-meta>
}
</ion-content>

View File

@@ -0,0 +1,53 @@
@import '../../../theme/util/mixins';
.body {
@include border-radius-in-parallax(var(--border-radius-default));
margin: var(--spacing-md);
padding: var(--spacing-md);
background: var(--ion-item-background);
}
ion-item {
margin-block-end: var(--spacing-xl);
}
ion-title {
transition:
opacity 0.2s ease,
translate 0.2s ease;
}
h1 {
margin: var(--spacing-sm) var(--spacing-md);
font-weight: var(--font-weight-bold);
color: var(--ion-color-primary-contrast);
}
pre {
word-wrap: break-word;
white-space: pre-wrap;
}
stapps-mail-meta {
// css hack to make the element stick to the bottom of the scroll container even
// when the content is not filling it
position: sticky;
top: 100vh;
}
ion-accordion {
background: none;
}
.attachment {
display: flex;
align-items: center;
justify-content: space-between;
margin: var(--spacing-md) 0;
padding: var(--spacing-md);
border: 1px solid var(--ion-border-color);
border-radius: var(--border-radius-default);
}

View File

@@ -0,0 +1,17 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
import {EmailData} from './types';
import {TranslateModule} from '@ngx-translate/core';
import {TitleCasePipe} from '@angular/common';
@Component({
selector: 'stapps-mail-meta',
templateUrl: 'mail-meta.html',
styleUrl: 'mail-meta.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ParseIsoPipeModule, FormatPurePipeModule, TranslateModule, TitleCasePipe],
})
export class MailMetaComponent {
@Input({required: true}) mail: EmailData;
}

View File

@@ -0,0 +1,51 @@
<table slot="content">
<tr>
<th>{{ 'mail.FROM' | translate | titlecase }}</th>
<td>
<ul>
@for (from of mail.envelope.from; track from) {
<li>
@if (from.name) {
<span>{{ from.name }}</span>
}
<code>{{ from.address }}</code>
</li>
}
</ul>
</td>
</tr>
<tr>
<th>{{ 'mail.SENDER' | translate | titlecase }}</th>
<td>
<ul>
@for (sender of mail.envelope.sender; track sender) {
<li>
@if (sender.name) {
<span>{{ sender.name }}</span>
}
<code>{{ sender.address }}</code>
</li>
}
</ul>
</td>
</tr>
<tr>
<th>{{ 'mail.TO' | translate | titlecase }}</th>
<td>
<ul>
@for (to of mail.envelope.to; track to) {
<li>
@if (to.name) {
<span>{{ to.name }}</span>
}
<code>{{ to.address }}</code>
</li>
}
</ul>
</td>
</tr>
<tr>
<th>{{ 'mail.DATE' | translate | titlecase }}</th>
<td>{{ mail.envelope.date | dfnsParseIso | dfnsFormatPure: 'PPp' }}</td>
</tr>
</table>

View File

@@ -0,0 +1,35 @@
ul {
margin: 0;
padding: 0;
list-style: none;
}
code {
font-weight: 400;
font-style: italic;
color: var(--ion-color-dark);
}
span + code::before {
content: '';
}
th,
td {
font-size: 0.8em;
}
th {
padding-inline-end: var(--spacing-md);
text-align: left;
vertical-align: top;
}
td {
vertical-align: top;
}
table {
margin: var(--spacing-lg);
opacity: 0.8;
}

View File

@@ -0,0 +1,32 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {MailService} from './mail.service';
import {AsyncPipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
import {DataModule} from '../data/data.module';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {UtilModule} from 'src/app/util/util.module';
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
import {RouterModule} from '@angular/router';
@Component({
selector: 'stapps-mail-page',
templateUrl: 'mail-page.html',
styleUrl: 'mail-page.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
IonicModule,
DataModule,
IonIconModule,
UtilModule,
FormatPurePipeModule,
ParseIsoPipeModule,
RouterModule,
],
})
export class MailPageComponent {
mails = this.mailService.list();
constructor(readonly mailService: MailService) {}
}

View File

@@ -0,0 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-title>Mail</ion-title>
</ion-toolbar>
</ion-header>
<ion-content parallax>
<h1>Inbox</h1>
@if (mails | async; as mails) {
<ion-list>
@for (mail of mails; track mail) {
<ion-item [routerLink]="['/mail', mail.seq]" [class.unread]="!mail.flags.includes('\\Seen')">
<div slot="start" class="avatar">
@if (mail.envelope.from[0]; as from) {
<div>
{{ (from.name || from.address).charAt(0).toUpperCase() }}
</div>
}
</div>
<ion-label>
@for (from of mail.envelope.from; track from) {
<h2>{{ from.name || from.address }}</h2>
}
<p>{{ mail.envelope.subject }}</p>
</ion-label>
<ion-note slot="end">{{ mail.envelope.date | dfnsParseIso | dfnsFormatPure: 'PPp' }}</ion-note>
</ion-item>
}
</ion-list>
} @else {
<div>Loading...</div>
}
</ion-content>

View File

@@ -0,0 +1,33 @@
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
color: var(--ion-color-light-contrast);
background: var(--ion-color-light);
border-radius: 50%;
}
h1 {
margin-inline: var(--spacing-md);
color: var(--ion-color-primary-contrast);
}
ion-item.unread h2 {
font-weight: bold;
}
ion-item p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ion-list {
margin: var(--spacing-md);
border-radius: var(--border-radius-default);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2024 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {RouterModule} from '@angular/router';
import {NgModule} from '@angular/core';
import {MailPageComponent} from './mail-page.component';
import {MailDetailComponent} from './mail-detail.component';
@NgModule({
imports: [
RouterModule.forChild([
{path: 'mail', component: MailPageComponent},
{path: 'mail/:id', component: MailDetailComponent},
]),
],
})
export class MailModule {}

View File

@@ -0,0 +1,26 @@
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable, map, tap} from 'rxjs';
import {EmailData} from './types';
@Injectable({providedIn: 'root'})
export class MailService {
constructor(private httpClient: HttpClient) {}
list(): Observable<EmailData[]> {
return this.httpClient.get<EmailData[]>('http://localhost:4000/', {responseType: 'json'}).pipe(
tap(console.log),
map(it => it.sort((a: EmailData, b: EmailData) => b.envelope.date.localeCompare(a.envelope.date))),
);
}
getAttachment(id: string, attachment?: string): Observable<ArrayBuffer> {
return this.httpClient.get(`http://localhost:4000/${id}/attachment/${attachment ?? 'TEXT'}`, {
responseType: 'arraybuffer',
});
}
getMail(id: string): Observable<EmailData> {
return this.httpClient.get<EmailData>(`http://localhost:4000/${id}`, {responseType: 'json'});
}
}

View File

@@ -0,0 +1,23 @@
/* eslint-disable unicorn/no-null */
import {Pipe, PipeTransform} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
@Pipe({
name: 'mailAttachmentText',
standalone: true,
pure: true,
})
export class MailAttachmentTextPipe implements PipeTransform {
constructor(readonly sanitizer: DomSanitizer) {}
transform(attachment: null, encoding?: string): null;
transform(attachment: ArrayBuffer, encoding?: string): string;
transform(attachment: ArrayBuffer | null, encoding?: string): string | null;
transform(attachment: ArrayBuffer | null, encoding?: string): string | null {
if (attachment === null) {
return null;
}
const decoder = new TextDecoder(encoding ?? 'utf8');
return decoder.decode(attachment);
}
}

View File

@@ -0,0 +1,19 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {IonicModule} from '@ionic/angular';
import {DataSizePipe} from 'src/app/util/data-size.pipe';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {BodyStructure} from '../types';
@Component({
selector: 'stapps-mail-part-attachment',
templateUrl: 'mail-part-attachment.html',
styleUrl: 'mail-part-attachment.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonicModule, DataSizePipe, IonIconModule],
})
export class MailPartAttachmentComponent {
@Input({required: true}) part: BodyStructure;
@Input({required: true}) mail: string;
}

View File

@@ -0,0 +1,9 @@
<ion-card>
<ion-card-header>
@if (part.parameters?.name) {
<ion-card-title>{{ part.parameters?.name }}</ion-card-title>
}
<ion-card-subtitle>{{ part.size | dataSize }}</ion-card-subtitle>
</ion-card-header>
<ion-button fill="clear"><ion-icon slot="icon-only" name="download"></ion-icon></ion-button>
</ion-card>

View File

@@ -0,0 +1,21 @@
ion-card {
display: flex;
flex-direction: row;
width: fit-content;
margin: 0;
margin-block-start: var(--spacing-md);
outline: 1px solid var(--ion-border-color);
box-shadow: none;
}
ion-card-header {
padding: var(--spacing-sm) var(--spacing-md);
}
ion-card-title {
font-size: 1rem;
text-wrap: wrap;
word-break: break-word;
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable unicorn/no-useless-undefined */
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core';
import {BodyStructure} from '../types';
import {MailService} from '../mail.service';
import {AsyncPipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
import {ReplaySubject, mergeMap} from 'rxjs';
import {MailAttachmentTextPipe} from './mail-attachment-text.pipe';
import {MailPartAttachmentComponent} from './mail-part-attachment.component';
import {MailPreferredAlternativePipe} from './mail-preferred-alternative.pipe';
import {ShadowHtmlDirective} from './shadow-html.directive';
@Component({
selector: 'stapps-mail-part',
templateUrl: 'mail-part.html',
styleUrl: 'mail-part.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
IonicModule,
ShadowHtmlDirective,
MailAttachmentTextPipe,
MailPartAttachmentComponent,
MailPreferredAlternativePipe,
],
})
export class MailPartComponent implements OnChanges, OnInit {
@Input({required: true}) part: BodyStructure;
@Input({required: true}) mail: string;
data = new ReplaySubject<[BodyStructure, string]>(1);
content = this.data.pipe(mergeMap(([part, mail]) => this.mailService.getAttachment(mail, part.part)));
constructor(readonly mailService: MailService) {}
ngOnInit() {
this.data.next([this.part, this.mail]);
}
ngOnChanges(changes: SimpleChanges) {
if ('mail' in changes || 'part' in changes) {
this.data.next([this.part, this.mail]);
}
}
}

View File

@@ -0,0 +1,23 @@
@if (part.type === 'text/html') {
@if (content | async | mailAttachmentText: part.parameters?.charset; as content) {
<div class="html" [shadowHTML]="content"></div>
} @else {
<ion-skeleton-text animated></ion-skeleton-text>
}
} @else if (part.type === 'text/plain') {
@if (content | async | mailAttachmentText: part.parameters?.charset; as content) {
<pre>{{ content }}</pre>
} @else {
<ion-skeleton-text animated></ion-skeleton-text>
}
} @else if (part.type === 'multipart/alternative') {
@if (part.childNodes && part.childNodes.length > 0) {
<stapps-mail-part [part]="part.childNodes | mailPreferredAlternative" [mail]="mail"></stapps-mail-part>
}
} @else if (part.type.startsWith('multipart')) {
@for (child of part.childNodes; track child) {
<stapps-mail-part [part]="child" [mail]="mail"></stapps-mail-part>
}
} @else {
<stapps-mail-part-attachment [part]="part" [mail]="mail"></stapps-mail-part-attachment>
}

View File

@@ -0,0 +1,9 @@
.html {
overflow-x: auto;
}
pre {
font-family: inherit;
word-wrap: break-word;
white-space: pre-wrap;
}

View File

@@ -0,0 +1,13 @@
import {Pipe, PipeTransform} from '@angular/core';
import {BodyStructure} from '../types';
@Pipe({name: 'mailPreferredAlternative', standalone: true, pure: true})
export class MailPreferredAlternativePipe implements PipeTransform {
transform(value: BodyStructure[]): BodyStructure {
return (
value.find(part => part.type === 'text/html') ??
value.find(part => part.type === 'text/plain') ??
value[0]
);
}
}

View File

@@ -0,0 +1,18 @@
import {Directive, ElementRef, Host, Input} from '@angular/core';
import {sanitize} from 'dompurify';
@Directive({
selector: '[shadowHTML]',
standalone: true,
})
export class ShadowHtmlDirective {
@Input({required: true})
set shadowHTML(content: string) {
this.shadowRoot.innerHTML = '';
this.shadowRoot.append(sanitize(content, {RETURN_DOM_FRAGMENT: true, USE_PROFILES: {html: true}}));
}
shadowRoot = (this.elementRef.nativeElement as HTMLElement).attachShadow({mode: 'open'});
constructor(@Host() readonly elementRef: ElementRef) {}
}

View File

@@ -0,0 +1,44 @@
export interface BodyStructure {
type: string;
part?: string;
parameters?: {
name?: string;
encoding?: string;
charset?: string;
};
disposition: string;
dispositionParameters?: unknown;
size: number;
childNodes?: BodyStructure[];
}
export interface BodyPartInfo {
type: string;
name?: string;
encoding?: string;
size: number;
part?: string;
}
export interface EnvelopeAddress {
name: string;
address: string;
}
export interface Envelope {
date: string;
from: EnvelopeAddress[];
messageId: string;
replyTo: EnvelopeAddress[];
sender: EnvelopeAddress[];
subject: string;
to: EnvelopeAddress[];
}
export interface EmailData {
bodyStructure: BodyStructure;
labels: string[];
flags: string[];
envelope: Envelope;
seq: number;
}

View File

@@ -39,6 +39,11 @@
}
<ion-label>{{ 'name' | translateSimple: link }}</ion-label>
</div>
@if (link.beta) {
<ion-note>
<ion-badge color="warning">{{ 'beta' | translate }}</ion-badge>
</ion-note>
}
</ion-item>
}
</simple-swiper>

View File

@@ -50,6 +50,13 @@ ion-item {
}
}
ion-note {
position: absolute;
top: 0;
right: 0;
padding: var(--spacing-xs);
}
simple-swiper {
--swiper-slide-width: #{$width};

View File

@@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
/**
* Format a data size in bytes to a human readable string
*/
export function formatDataSize(value: number, precision = 2): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(precision)} ${units[unit]}`;
}
@Pipe({
name: 'dataSize',
pure: true,
standalone: true,
})
export class DataSizePipe implements PipeTransform {
transform(value: number, precision = 2): string {
return formatDataSize(value, precision);
}
}

View File

@@ -0,0 +1,7 @@
import {Directive} from '@angular/core';
@Directive({
selector: 'ng-template[skeletonData]',
standalone: true,
})
export class SkeletonDataDirective {}

View File

@@ -0,0 +1,7 @@
import {Directive} from '@angular/core';
@Directive({
selector: 'ng-template[skeletonPlaceholder]',
standalone: true,
})
export class SkeletonPlaceholderDirective {}

View File

@@ -0,0 +1,22 @@
import {ChangeDetectionStrategy, Component, ContentChild, Input, TemplateRef} from '@angular/core';
import {Observable} from 'rxjs';
import {SkeletonDataDirective} from './skeleton-data.directive';
import {SkeletonPlaceholderDirective} from './skeleton-placeholder.directive';
@Component({
selector: 'stapps-skeleton',
templateUrl: 'skeleton.html',
styleUrl: 'skeleton.scss',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SkeletonComponent<T> {
@ContentChild(SkeletonDataDirective) dataTemplate: TemplateRef<SkeletonDataDirective>;
@ContentChild(SkeletonPlaceholderDirective) placeholderTemplate: TemplateRef<SkeletonPlaceholderDirective>;
@Input()
set data(value: Observable<T> | Promise<T>) {}
static ngTemplateContextGuard<
}

View File

View File

View File

@@ -8,6 +8,7 @@
"export": "Exportieren",
"share": "Teilen",
"timeSuffix": "Uhr",
"beta": "Beta",
"ratings": {
"thank_you": "Vielen Dank für die Bewertung!"
},
@@ -386,6 +387,12 @@
}
}
},
"mail": {
"FROM": "von",
"SENDER": "Absender",
"TO": "an",
"DATE": "Datum"
},
"menu": {
"context": {
"title": "Kontext Menü",

View File

@@ -7,6 +7,7 @@
"back": "back",
"export": "Export",
"share": "Share",
"beta": "beta",
"timeSuffix": "",
"ratings": {
"thank_you": "Thank you for your feedback!"
@@ -386,6 +387,12 @@
}
}
},
"mail": {
"FROM": "from",
"SENDER": "sender",
"TO": "to",
"DATE": "date"
},
"menu": {
"context": {
"title": "context menu",

View File

@@ -49,6 +49,7 @@ export interface SCSectionLink extends SCThing {
link: string[];
needsAuth?: true;
icon?: string;
beta?: true;
}
export interface SCSection extends SCThing {
@@ -150,6 +151,18 @@ export const profilePageSections: SCSection[] = [
},
...SCSectionLinkConstantValues,
},
{
name: 'Mail',
icon: SCIcon.mail,
link: ['/mail'],
beta: true,
translations: {
de: {
name: 'Email',
},
},
...SCSectionLinkConstantValues,
},
],
translations: {
de: {