mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 17:12:43 +00:00
feat: email client prototype
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
frontend/app/src/app/modules/auth/mail-auth.service.ts
Normal file
10
frontend/app/src/app/modules/auth/mail-auth.service.ts
Normal 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() {}
|
||||
}
|
||||
51
frontend/app/src/app/modules/mail/mail-detail.component.ts
Normal file
51
frontend/app/src/app/modules/mail/mail-detail.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
65
frontend/app/src/app/modules/mail/mail-detail.html
Normal file
65
frontend/app/src/app/modules/mail/mail-detail.html
Normal 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>
|
||||
53
frontend/app/src/app/modules/mail/mail-detail.scss
Normal file
53
frontend/app/src/app/modules/mail/mail-detail.scss
Normal 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);
|
||||
}
|
||||
17
frontend/app/src/app/modules/mail/mail-meta.component.ts
Normal file
17
frontend/app/src/app/modules/mail/mail-meta.component.ts
Normal 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;
|
||||
}
|
||||
51
frontend/app/src/app/modules/mail/mail-meta.html
Normal file
51
frontend/app/src/app/modules/mail/mail-meta.html
Normal 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>
|
||||
35
frontend/app/src/app/modules/mail/mail-meta.scss
Normal file
35
frontend/app/src/app/modules/mail/mail-meta.scss
Normal 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;
|
||||
}
|
||||
32
frontend/app/src/app/modules/mail/mail-page.component.ts
Normal file
32
frontend/app/src/app/modules/mail/mail-page.component.ts
Normal 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) {}
|
||||
}
|
||||
34
frontend/app/src/app/modules/mail/mail-page.html
Normal file
34
frontend/app/src/app/modules/mail/mail-page.html
Normal 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>
|
||||
33
frontend/app/src/app/modules/mail/mail-page.scss
Normal file
33
frontend/app/src/app/modules/mail/mail-page.scss
Normal 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);
|
||||
}
|
||||
28
frontend/app/src/app/modules/mail/mail.module.ts
Normal file
28
frontend/app/src/app/modules/mail/mail.module.ts
Normal 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 {}
|
||||
26
frontend/app/src/app/modules/mail/mail.service.ts
Normal file
26
frontend/app/src/app/modules/mail/mail.service.ts
Normal 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'});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/app/src/app/modules/mail/parts/mail-part.html
Normal file
23
frontend/app/src/app/modules/mail/parts/mail-part.html
Normal 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>
|
||||
}
|
||||
9
frontend/app/src/app/modules/mail/parts/mail-part.scss
Normal file
9
frontend/app/src/app/modules/mail/parts/mail-part.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.html {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
44
frontend/app/src/app/modules/mail/types.ts
Normal file
44
frontend/app/src/app/modules/mail/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -50,6 +50,13 @@ ion-item {
|
||||
}
|
||||
}
|
||||
|
||||
ion-note {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
simple-swiper {
|
||||
--swiper-slide-width: #{$width};
|
||||
|
||||
|
||||
25
frontend/app/src/app/util/data-size.pipe.ts
Normal file
25
frontend/app/src/app/util/data-size.pipe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
frontend/app/src/app/util/skeleton-data.directive.ts
Normal file
7
frontend/app/src/app/util/skeleton-data.directive.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import {Directive} from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: 'ng-template[skeletonData]',
|
||||
standalone: true,
|
||||
})
|
||||
export class SkeletonDataDirective {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {Directive} from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: 'ng-template[skeletonPlaceholder]',
|
||||
standalone: true,
|
||||
})
|
||||
export class SkeletonPlaceholderDirective {}
|
||||
22
frontend/app/src/app/util/skeleton.component.ts
Normal file
22
frontend/app/src/app/util/skeleton.component.ts
Normal 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<
|
||||
}
|
||||
0
frontend/app/src/app/util/skeleton.html
Normal file
0
frontend/app/src/app/util/skeleton.html
Normal file
0
frontend/app/src/app/util/skeleton.scss
vendored
Normal file
0
frontend/app/src/app/util/skeleton.scss
vendored
Normal 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ü",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user