mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-21 17:12:43 +00:00
feat: use postal mime parser
This commit is contained in:
@@ -90,6 +90,7 @@
|
||||
"@openstapps/core": "workspace:*",
|
||||
"@transistorsoft/capacitor-background-fetch": "5.2.0",
|
||||
"@types/dom-view-transitions": "1.0.4",
|
||||
"asn1js": "^3.0.5",
|
||||
"capacitor-secure-storage-plugin": "0.9.0",
|
||||
"cordova-plugin-calendar": "5.1.6",
|
||||
"date-fns": "3.6.0",
|
||||
@@ -98,6 +99,8 @@
|
||||
"geojson": "0.5.0",
|
||||
"ionic-appauth": "0.9.0",
|
||||
"jsonpath-plus": "6.0.1",
|
||||
"libbase64": "^1.3.0",
|
||||
"libqp": "^2.1.0",
|
||||
"maplibre-gl": "4.0.2",
|
||||
"material-symbols": "0.17.1",
|
||||
"moment": "2.30.1",
|
||||
@@ -113,6 +116,7 @@
|
||||
"semver": "7.6.0",
|
||||
"swiper": "8.4.5",
|
||||
"tslib": "2.6.2",
|
||||
"zod": "^3.23.8",
|
||||
"zone.js": "0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -145,6 +149,7 @@
|
||||
"@types/fontkit": "2.0.7",
|
||||
"@types/geojson": "1.0.6",
|
||||
"@types/glob": "8.1.0",
|
||||
"@types/imapflow": "1.0.18",
|
||||
"@types/jasmine": "5.1.4",
|
||||
"@types/jasminewd2": "2.0.13",
|
||||
"@types/jsonpath": "0.2.0",
|
||||
|
||||
@@ -15,12 +15,11 @@
|
||||
import {CommonModule, LocationStrategy, PathLocationStrategy, registerLocaleData} from '@angular/common';
|
||||
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule} from '@angular/common/http';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import {APP_INITIALIZER, NgModule} from '@angular/core';
|
||||
import {APP_INITIALIZER, Injectable, NgModule} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
import {RouteReuseStrategy} from '@angular/router';
|
||||
import {IonicModule, IonicRouteStrategy, Platform} from '@ionic/angular';
|
||||
import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
|
||||
import moment from 'moment';
|
||||
import 'moment/min/locales';
|
||||
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
|
||||
@@ -72,6 +71,7 @@ import {SplashScreen} from '@capacitor/splash-screen';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import {Protocol} from 'pmtiles';
|
||||
import {MailModule} from './modules/mail/mail.module';
|
||||
import {Observable, from} from 'rxjs';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -130,12 +130,16 @@ export function initializerFactory(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* @param http TODO
|
||||
*/
|
||||
export function createTranslateLoader(http: HttpClient) {
|
||||
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ImportTranslateLoader {
|
||||
static translations: Record<string, () => Promise<{default: object}>> = {
|
||||
de: () => import('../assets/i18n/de.json'),
|
||||
en: () => import('../assets/i18n/en.json'),
|
||||
};
|
||||
|
||||
getTranslation(lang: string): Observable<object> {
|
||||
return from(ImportTranslateLoader.translations[lang]().then(it => it.default));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,7 +183,7 @@ export function createTranslateLoader(http: HttpClient) {
|
||||
loader: {
|
||||
deps: [HttpClient],
|
||||
provide: TranslateLoader,
|
||||
useFactory: createTranslateLoader,
|
||||
useClass: ImportTranslateLoader,
|
||||
},
|
||||
}),
|
||||
UtilModule,
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
|
||||
import {MailService} from './mail.service';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
import {AsyncPipe, TitleCasePipe} 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 {mergeMap} from 'rxjs';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {MailPartComponent} from './parts/mail-part.component';
|
||||
import {MailMetaComponent} from './mail-meta.component';
|
||||
import {materialFade} from 'src/app/animation/material-motion';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'stapps-mail-detail',
|
||||
templateUrl: 'mail-detail.html',
|
||||
styleUrl: 'mail-detail.scss',
|
||||
animations: [materialFade],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
@@ -27,15 +29,13 @@ import {MailMetaComponent} from './mail-meta.component';
|
||||
FormatPurePipeModule,
|
||||
ParseIsoPipeModule,
|
||||
RouterModule,
|
||||
MailPartComponent,
|
||||
MailMetaComponent,
|
||||
ShadowHtmlDirective,
|
||||
TranslateModule,
|
||||
TitleCasePipe,
|
||||
],
|
||||
})
|
||||
export class MailDetailComponent {
|
||||
mail = this.activatedRoute.params.pipe(
|
||||
mergeMap(parameters => this.mailService.getMail(parameters.id)),
|
||||
tap(console.log),
|
||||
);
|
||||
mail = this.activatedRoute.params.pipe(mergeMap(({id}) => this.mailService.getEmail(id)));
|
||||
|
||||
collapse = signal(false);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
[style.translate]="collapse() ? '0' : '0 10px'"
|
||||
>
|
||||
@if (mail | async; as mail) {
|
||||
{{ mail.envelope.subject }}
|
||||
{{ mail.subject?.value }}
|
||||
} @else {
|
||||
<ion-skeleton-text animated style="width: 100px; height: 20px"></ion-skeleton-text>
|
||||
}
|
||||
@@ -25,41 +25,69 @@
|
||||
</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>
|
||||
@if (mail | async; as mail) {
|
||||
<h1 @materialFade>{{ mail.subject?.value }}</h1>
|
||||
<aside @materialFade>
|
||||
<strong class="from">
|
||||
{{ mail.from.value.name || mail.from.value.address }}
|
||||
@if (mail.from.signature?.valid === true) {
|
||||
<ion-icon name="verified" [fill]="true" @materialFade></ion-icon>
|
||||
} @else if (mail.from.signature?.valid === false) {
|
||||
<ion-icon name="gpp_bad" color="danger" [fill]="true" @materialFade></ion-icon>
|
||||
}
|
||||
</strong>
|
||||
<div class="to">
|
||||
to
|
||||
@for (to of mail.to; track to) {
|
||||
<span>{{ to.value.name || to.value.address }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (mail.cc) {
|
||||
<div class="cc">
|
||||
cc
|
||||
@for (cc of mail.cc; track cc) {
|
||||
<span>{{ cc.value.name || cc.value.address }}</span>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</aside>
|
||||
@if (mail.html) {
|
||||
<main @materialFade>
|
||||
<div class="html" [shadowHTML]="mail.html.value"></div>
|
||||
</main>
|
||||
} @else if (mail.text) {
|
||||
<main @materialFade>
|
||||
<pre>{{ mail.text.value }}</pre>
|
||||
</main>
|
||||
}
|
||||
</div>
|
||||
@if (mail | async; as mail) {
|
||||
<stapps-mail-meta [mail]="mail"></stapps-mail-meta>
|
||||
<ion-list>
|
||||
@for (attachment of mail.attachments; track attachment) {
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>{{ attachment.value.filename }}</ion-card-title>
|
||||
<ion-card-subtitle>{{ attachment.value.size }}</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-button fill="clear" (click)="todo()">
|
||||
<ion-icon slot="icon-only" name="download"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
}
|
||||
</ion-list>
|
||||
<footer>
|
||||
<div>
|
||||
<table>
|
||||
@if (mail.date) {
|
||||
<tr>
|
||||
<th>{{ 'mail.DATE' | translate | titlecase }}</th>
|
||||
<td>
|
||||
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
</ion-content>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
@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);
|
||||
}
|
||||
@@ -24,20 +16,68 @@ h1 {
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: var(--spacing-xs) var(--spacing-md);
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
|
||||
.to {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
> span:has(+ span)::after {
|
||||
content: ',';
|
||||
}
|
||||
}
|
||||
|
||||
.from {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
main {
|
||||
@include border-radius-in-parallax(var(--border-radius-default));
|
||||
|
||||
margin: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--ion-item-background);
|
||||
}
|
||||
|
||||
.html {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
stapps-mail-meta {
|
||||
footer {
|
||||
// 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;
|
||||
> div {
|
||||
margin: var(--spacing-lg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-inline-start: var(--spacing-md);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
|
||||
import {RouterModule} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
@@ -21,7 +21,7 @@ import {RouterModule} from '@angular/router';
|
||||
IonIconModule,
|
||||
UtilModule,
|
||||
FormatPurePipeModule,
|
||||
ParseIsoPipeModule,
|
||||
IsTodayPipeModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
@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')">
|
||||
<ion-item [routerLink]="['/mail', mail.seq]" [class.unread]="!mail.flags.has('\\Seen')">
|
||||
<div slot="start" class="avatar">
|
||||
@if (mail.envelope.from[0]; as from) {
|
||||
<div>
|
||||
{{ (from.name || from.address).charAt(0).toUpperCase() }}
|
||||
{{ (from.name || from.address)?.charAt(0)?.toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -24,7 +24,13 @@
|
||||
}
|
||||
<p>{{ mail.envelope.subject }}</p>
|
||||
</ion-label>
|
||||
<ion-note slot="end">{{ mail.envelope.date | dfnsParseIso | dfnsFormatPure: 'PPp' }}</ion-note>
|
||||
<ion-note slot="end">
|
||||
@if (mail.envelope.date | dfnsIsToday) {
|
||||
{{ mail.envelope.date | dfnsFormatPure: 'p' }}
|
||||
} @else {
|
||||
{{ mail.envelope.date | dfnsFormatPure: 'P' }}
|
||||
}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
}
|
||||
</ion-list>
|
||||
|
||||
@@ -1,26 +1,257 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Observable, map, tap} from 'rxjs';
|
||||
import {EmailData} from './types';
|
||||
import {Observable, map, tap, mergeMap, of, merge, forkJoin, catchError, mergeScan} from 'rxjs';
|
||||
import PostalMime from 'postal-mime';
|
||||
import {ContentInfo, SignedData} from 'pkijs';
|
||||
import {
|
||||
RawEmail,
|
||||
Email,
|
||||
SignedValue,
|
||||
RawEmailBodyStructure,
|
||||
EmailAttachment,
|
||||
EmailAttachmentRemote,
|
||||
Signature,
|
||||
} from './schema';
|
||||
import {z} from 'zod';
|
||||
import {compareAsc} from 'date-fns';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class MailService {
|
||||
constructor(private httpClient: HttpClient) {}
|
||||
|
||||
list(): Observable<EmailData[]> {
|
||||
return this.httpClient.get<EmailData[]>('http://localhost:4000/', {responseType: 'json'}).pipe(
|
||||
private listRawEmails(): Observable<RawEmail[]> {
|
||||
return this.httpClient.get('http://localhost:4000/', {responseType: 'json'}).pipe(
|
||||
map<unknown, RawEmail[]>(it => {
|
||||
return z.array(RawEmail).parse(it);
|
||||
}),
|
||||
map(it => it.sort((a, b) => compareAsc(b.envelope.date, a.envelope.date))),
|
||||
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'}`, {
|
||||
private getRawEmail(id: string): Observable<RawEmail> {
|
||||
return this.httpClient
|
||||
.get(`http://localhost:4000/${id}`, {responseType: 'json'})
|
||||
.pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
||||
}
|
||||
|
||||
private getFullAttachment(id: string | number, attachment: string): Observable<ArrayBuffer> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/raw/${attachment}`, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
}
|
||||
|
||||
getMail(id: string): Observable<EmailData> {
|
||||
return this.httpClient.get<EmailData>(`http://localhost:4000/${id}`, {responseType: 'json'});
|
||||
private getRawAttachment(id: string | number, attachment = ''): Observable<ArrayBuffer> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/attachment/${attachment}`, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRawEmail(email: RawEmail): Observable<Email> {
|
||||
console.log(email);
|
||||
|
||||
function value(value: undefined): undefined;
|
||||
function value<T>(value: T): SignedValue<T>;
|
||||
function value<T>(value: T | undefined): SignedValue<T> | undefined {
|
||||
return value === undefined ? undefined : {value};
|
||||
}
|
||||
|
||||
if (
|
||||
email.bodyStructure.type === 'application/x-pkcs7-mime' ||
|
||||
email.bodyStructure.type === 'application/pkcs7-mime'
|
||||
) {
|
||||
return this.getRawAttachment(email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
|
||||
mergeMap(async buffer => {
|
||||
const info = ContentInfo.fromBER(buffer);
|
||||
const signedData = new SignedData({schema: info.content});
|
||||
const valid = await signedData
|
||||
.verify({signer: 0, data: signedData.encapContentInfo.eContent?.valueBeforeDecodeView})
|
||||
.catch(() => false);
|
||||
const content = new TextDecoder().decode(
|
||||
signedData.encapContentInfo.eContent?.valueBeforeDecodeView,
|
||||
);
|
||||
const signedEmail = await PostalMime.parse(content);
|
||||
|
||||
function signed(value: undefined): undefined;
|
||||
function signed<T>(value: T): SignedValue<T>;
|
||||
function signed<T>(value: T | undefined): SignedValue<T> | undefined {
|
||||
return value === undefined
|
||||
? undefined
|
||||
: {
|
||||
value,
|
||||
signature: {
|
||||
type: 'pkcs7',
|
||||
valid,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result: Email = {
|
||||
subject: signedEmail.subject ? signed(signedEmail.subject) : value(email.envelope.subject),
|
||||
from: signed({
|
||||
name: signedEmail.from.name || undefined,
|
||||
address: signedEmail.from.address || undefined,
|
||||
}),
|
||||
to: signedEmail.to?.map(({name, address}) =>
|
||||
signed({
|
||||
name,
|
||||
address,
|
||||
}),
|
||||
),
|
||||
date: signedEmail.date ? signed(new Date(signedEmail.date)) : value(email.envelope.date),
|
||||
html: signedEmail.html ? signed(signedEmail.html) : undefined,
|
||||
text: signedEmail.text ? signed(signedEmail.text) : undefined,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const traverse = (
|
||||
item: RawEmailBodyStructure,
|
||||
result: Pick<Email, 'attachments' | 'text' | 'html'>,
|
||||
signature?: Signature,
|
||||
): Observable<Pick<Email, 'attachments' | 'text' | 'html'>> => {
|
||||
// https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
|
||||
if (item.type === 'multipart/signed' && item.parameters?.protocol === 'application/pkcs7-signature') {
|
||||
return forkJoin({
|
||||
data: this.getFullAttachment(email.seq, item.childNodes![0].part!),
|
||||
signature: this.getRawAttachment(email.seq, item.childNodes![1].part!),
|
||||
}).pipe(
|
||||
mergeMap(({data, signature}) => {
|
||||
const info = ContentInfo.fromBER(signature);
|
||||
const signedData = new SignedData({schema: info.content});
|
||||
return signedData.verify({signer: 0, data});
|
||||
}),
|
||||
catchError(error => {
|
||||
console.log(error);
|
||||
return of(false);
|
||||
}),
|
||||
mergeMap(valid => traverse(item.childNodes![0], result, {type: 'pkcs7', valid})),
|
||||
);
|
||||
} else if (item.type.startsWith('multipart/')) {
|
||||
return forkJoin(item.childNodes!.map(child => traverse(child, result, signature))).pipe(
|
||||
map(children => children[0]),
|
||||
);
|
||||
} else if (item.type === 'text/plain') {
|
||||
return this.getRawAttachment(email.seq, item.part ?? 'TEXT').pipe(
|
||||
map(text => {
|
||||
result.html = {value: new TextDecoder().decode(text), signature};
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
} else if (item.type === 'text/html') {
|
||||
return this.getRawAttachment(email.seq, item.part ?? 'TEXT').pipe(
|
||||
map(html => {
|
||||
result.html = {value: new TextDecoder().decode(html), signature};
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
} else if (item.part !== undefined) {
|
||||
result.attachments.push({value: {part: item.part, size: item.size ?? NaN, filename: ''}});
|
||||
return of(result);
|
||||
} else {
|
||||
return of(result);
|
||||
}
|
||||
};
|
||||
|
||||
return traverse(email.bodyStructure, {attachments: []}).pipe(
|
||||
map(partial => ({
|
||||
...partial,
|
||||
subject: value(email.envelope.subject),
|
||||
from: value({
|
||||
name: email.envelope.from[0]?.name || undefined,
|
||||
address: email.envelope.from[0]?.address || undefined,
|
||||
}),
|
||||
date: value(email.envelope.date),
|
||||
})),
|
||||
tap(console.log),
|
||||
);
|
||||
}
|
||||
|
||||
getEmail(id: string): Observable<Email> {
|
||||
return this.getRawEmail(id).pipe(
|
||||
mergeMap(it => this.resolveRawEmail(it)),
|
||||
tap(console.log),
|
||||
);
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.listRawEmails().pipe(
|
||||
tap(it => {
|
||||
const email = it[7];
|
||||
console.log(email);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/*getREmail(id: string): Observable<SignedEmail> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/attachment`, {responseType: 'arraybuffer'}).pipe(
|
||||
map(buffer => new TextDecoder().decode(buffer)),
|
||||
mergeMap(async content => {
|
||||
const email = (await PostalMime.parse(content)) as SignedEmail;
|
||||
email.signature = 'none';
|
||||
|
||||
const signedMail = email.attachments.find(attachment =>
|
||||
/application\/(x-)?pkcs7-mime/.test(attachment.mimeType),
|
||||
);
|
||||
if (signedMail) {
|
||||
const info = ContentInfo.fromBER(signedMail.content);
|
||||
const signedData = new SignedData({schema: info.content});
|
||||
const valid = await signedData
|
||||
.verify({
|
||||
signer: 0,
|
||||
data: signedData.encapContentInfo.eContent?.valueBeforeDecodeView,
|
||||
})
|
||||
.catch(() => false);
|
||||
const content = new TextDecoder().decode(
|
||||
signedData.encapContentInfo.eContent?.valueBeforeDecodeView,
|
||||
);
|
||||
const signedEmail = (await PostalMime.parse(content)) as SignedEmail;
|
||||
signedEmail.signature = valid ? 'valid' : 'invalid';
|
||||
return signedEmail;
|
||||
}
|
||||
|
||||
const signatureIndex = email.attachments.findIndex(attachment =>
|
||||
/application\/(x-)?pkcs7-signature/.test(attachment.mimeType),
|
||||
);
|
||||
if (signatureIndex === -1) {
|
||||
return email;
|
||||
}
|
||||
|
||||
email.signature = 'unsupported';
|
||||
|
||||
const signature = email.attachments.splice(signatureIndex, 1)[0];
|
||||
const info = ContentInfo.fromBER(signature.content);
|
||||
const signedData = new SignedData({schema: info.content});
|
||||
|
||||
const boundary = email.headers
|
||||
.find(header => header.key?.toLowerCase() === 'content-type')
|
||||
?.value?.match(/boundary=["']?([^"'\s;]+)["']?/)?.[1];
|
||||
if (boundary === undefined) {
|
||||
console.warn('No boundary found');
|
||||
return email;
|
||||
}
|
||||
|
||||
const parts = content.split(`\r\n--${boundary}\r\n`);
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Invalid parts', parts);
|
||||
return email;
|
||||
}
|
||||
|
||||
const valid = await signedData
|
||||
.verify({signer: 0, data: new TextEncoder().encode(parts[1])})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
return false;
|
||||
});
|
||||
email.signature = valid ? 'valid' : 'invalid';
|
||||
|
||||
return email;
|
||||
}),
|
||||
tap(console.log),
|
||||
);
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/* 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
@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>
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.html {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
94
frontend/app/src/app/modules/mail/schema.ts
Normal file
94
frontend/app/src/app/modules/mail/schema.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const RawEmailAddress = z.object({
|
||||
name: z.optional(z.string()),
|
||||
address: z.optional(z.string()),
|
||||
});
|
||||
|
||||
export type RawEmailAddress = z.infer<typeof RawEmailAddress>;
|
||||
|
||||
export const RawEmailEnvelope = z.object({
|
||||
date: z.coerce.date(),
|
||||
subject: z.string(),
|
||||
messageId: z.string(),
|
||||
inReplyTo: z.optional(z.string()),
|
||||
from: z.array(RawEmailAddress),
|
||||
sender: z.array(RawEmailAddress),
|
||||
replyTo: z.array(RawEmailAddress),
|
||||
to: z.array(RawEmailAddress),
|
||||
cc: z.optional(z.array(RawEmailAddress)),
|
||||
bcc: z.optional(z.array(RawEmailAddress)),
|
||||
});
|
||||
|
||||
export type RawEmailEnvelope = z.infer<typeof RawEmailEnvelope>;
|
||||
|
||||
const RawEmailBodyStructureBase = z.object({
|
||||
part: z.optional(z.string()),
|
||||
type: z.string(),
|
||||
parameters: z.optional(z.record(z.string(), z.string())),
|
||||
encoding: z.optional(z.enum(['7bit', '8bit', 'binary', 'base64', 'quoted-printable'])),
|
||||
size: z.optional(z.number()),
|
||||
envelope: z.optional(RawEmailEnvelope),
|
||||
disposition: z.optional(z.string()),
|
||||
dispositionParameters: z.optional(z.record(z.string(), z.string())),
|
||||
});
|
||||
|
||||
export type RawEmailBodyStructure = z.infer<typeof RawEmailBodyStructureBase> & {
|
||||
childNodes?: RawEmailBodyStructure[];
|
||||
};
|
||||
|
||||
export const RawEmailBodyStructure: z.ZodType<RawEmailBodyStructure> = RawEmailBodyStructureBase.extend({
|
||||
childNodes: z.optional(z.lazy(() => z.array(RawEmailBodyStructure))),
|
||||
});
|
||||
|
||||
export const RawEmail = z.object({
|
||||
bodyStructure: RawEmailBodyStructure,
|
||||
labels: z.array(z.string()).transform(it => new Set(it)),
|
||||
flags: z.array(z.string()).transform(it => new Set(it)),
|
||||
envelope: RawEmailEnvelope,
|
||||
seq: z.coerce.string(),
|
||||
});
|
||||
|
||||
export type RawEmail = z.infer<typeof RawEmail>;
|
||||
|
||||
export interface Signature {
|
||||
type: 'pkcs7';
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SignedValue<T> {
|
||||
value: T;
|
||||
signature?: Signature;
|
||||
}
|
||||
|
||||
export interface EmailAddress {
|
||||
name?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface EmailAttachmentBase {
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface EmailAttachmentRemote extends EmailAttachmentBase {
|
||||
part: string;
|
||||
}
|
||||
|
||||
export interface EmailAttachmentLocal extends EmailAttachmentBase {
|
||||
content: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type EmailAttachment = EmailAttachmentRemote | EmailAttachmentLocal;
|
||||
|
||||
export interface Email {
|
||||
subject?: SignedValue<string>;
|
||||
date: SignedValue<Date>;
|
||||
from: SignedValue<EmailAddress>;
|
||||
to?: SignedValue<EmailAddress>[];
|
||||
cc?: SignedValue<EmailAddress>[];
|
||||
bcc?: SignedValue<EmailAddress>[];
|
||||
html?: SignedValue<string>;
|
||||
text?: SignedValue<string>;
|
||||
attachments: SignedValue<EmailAttachment>[];
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -388,6 +388,10 @@
|
||||
}
|
||||
},
|
||||
"mail": {
|
||||
"SIGNATURE_VALID": "Signatur gültig",
|
||||
"SIGNATURE_INVALID": "Signatur ungültig",
|
||||
"SIGNATURE_UNSUPPORTED": "Signatur nicht unterstützt",
|
||||
"ID": "ID",
|
||||
"FROM": "von",
|
||||
"SENDER": "Absender",
|
||||
"TO": "an",
|
||||
|
||||
@@ -388,6 +388,10 @@
|
||||
}
|
||||
},
|
||||
"mail": {
|
||||
"SIGNATURE_VALID": "signature valid",
|
||||
"SIGNATURE_INVALID": "signature invalid",
|
||||
"SIGNATURE_UNSUPPORTED": "signature unsupported",
|
||||
"ID": "ID",
|
||||
"FROM": "from",
|
||||
"SENDER": "sender",
|
||||
"TO": "to",
|
||||
|
||||
Binary file not shown.
@@ -18,7 +18,7 @@
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="./assets/icon/favicon.png" />
|
||||
|
||||
<!-- add to homescreen for ios -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
Reference in New Issue
Block a user