diff --git a/backend/mail-plugin/src/cli.ts b/backend/mail-plugin/src/cli.ts index 59f23b58..73eb3c8d 100644 --- a/backend/mail-plugin/src/cli.ts +++ b/backend/mail-plugin/src/cli.ts @@ -77,22 +77,37 @@ app.get('/:id', async (request, response) => { await client.logout(); }); -app.get('/:id/attachment/:attachment', async (request, response) => { +app.get('/:id/attachment/:attachment?', async (request, response) => { const client = response.locals.client as ImapFlow; await client.connect(); const lock = await client.getMailboxLock('INBOX'); try { const message = await client.download(request.params.id, request.params.attachment); - const body = await new Promise(resolve => { - let body = ''; - message.content.on('data', chunk => { - body += chunk.toString(); - }); - message.content.on('end', () => { - resolve(body); - }); + message.content.on('data', chunk => { + response.write(chunk); }); - response.send(body); + message.content.on('end', () => { + response.end(); + }); + } finally { + lock.release(); + } + await client.logout(); +}); + +app.get('/:id/raw/:part', async (request, response) => { + const client = response.locals.client as ImapFlow; + await client.connect(); + const lock = await client.getMailboxLock('INBOX'); + try { + const message = await client.fetchOne(request.params.id, { + bodyParts: [`${request.params.part}.mime`, request.params.part], + }); + + response.write(message.bodyParts.get(`${request.params.part}.mime`)); + response.write(message.bodyParts.get(request.params.part)); + + response.end(); } finally { lock.release(); } diff --git a/frontend/app/package.json b/frontend/app/package.json index c357d8be..0f73616a 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -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", diff --git a/frontend/app/src/app/app.module.ts b/frontend/app/src/app/app.module.ts index 194370ec..f6bba3ab 100644 --- a/frontend/app/src/app/app.module.ts +++ b/frontend/app/src/app/app.module.ts @@ -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 Promise<{default: object}>> = { + de: () => import('../assets/i18n/de.json'), + en: () => import('../assets/i18n/en.json'), + }; + + getTranslation(lang: string): Observable { + 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, diff --git a/frontend/app/src/app/modules/mail/mail-detail.component.ts b/frontend/app/src/app/modules/mail/mail-detail.component.ts index 11f62da9..21961622 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.component.ts +++ b/frontend/app/src/app/modules/mail/mail-detail.component.ts @@ -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); diff --git a/frontend/app/src/app/modules/mail/mail-detail.html b/frontend/app/src/app/modules/mail/mail-detail.html index 17035338..837270df 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.html +++ b/frontend/app/src/app/modules/mail/mail-detail.html @@ -8,7 +8,7 @@ [style.translate]="collapse() ? '0' : '0 10px'" > @if (mail | async; as mail) { - {{ mail.envelope.subject }} + {{ mail.subject?.value }} } @else { } @@ -25,41 +25,69 @@ -

- @if (mail | async; as mail) { - {{ mail.envelope.subject }} - } @else { - - } -

-
- @if (mail | async; as mail) { - -
- @if (mail.envelope.from[0]; as from) { -
- {{ (from.name || from.address).charAt(0).toUpperCase() }} -
+ @if (mail | async; as mail) { +

{{ mail.subject?.value }}

+ + @if (mail.html) { +
+
+
+ } @else if (mail.text) { +
+
{{ mail.text.value }}
+
} -
- @if (mail | async; as mail) { - + + @for (attachment of mail.attachments; track attachment) { + + + {{ attachment.value.filename }} + {{ attachment.value.size }} + + + + + + + + } + +
+
+ + @if (mail.date) { + + + + + } +
{{ 'mail.DATE' | translate | titlecase }} + +
+
+
} diff --git a/frontend/app/src/app/modules/mail/mail-detail.scss b/frontend/app/src/app/modules/mail/mail-detail.scss index 2344f68c..28b00972 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.scss +++ b/frontend/app/src/app/modules/mail/mail-detail.scss @@ -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 { diff --git a/frontend/app/src/app/modules/mail/mail-meta.component.ts b/frontend/app/src/app/modules/mail/mail-meta.component.ts deleted file mode 100644 index be181c58..00000000 --- a/frontend/app/src/app/modules/mail/mail-meta.component.ts +++ /dev/null @@ -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; -} diff --git a/frontend/app/src/app/modules/mail/mail-meta.html b/frontend/app/src/app/modules/mail/mail-meta.html deleted file mode 100644 index 8a38fcf5..00000000 --- a/frontend/app/src/app/modules/mail/mail-meta.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - -
{{ 'mail.FROM' | translate | titlecase }} -
    - @for (from of mail.envelope.from; track from) { -
  • - @if (from.name) { - {{ from.name }} - } - {{ from.address }} -
  • - } -
-
{{ 'mail.SENDER' | translate | titlecase }} -
    - @for (sender of mail.envelope.sender; track sender) { -
  • - @if (sender.name) { - {{ sender.name }} - } - {{ sender.address }} -
  • - } -
-
{{ 'mail.TO' | translate | titlecase }} -
    - @for (to of mail.envelope.to; track to) { -
  • - @if (to.name) { - {{ to.name }} - } - {{ to.address }} -
  • - } -
-
{{ 'mail.DATE' | translate | titlecase }}{{ mail.envelope.date | dfnsParseIso | dfnsFormatPure: 'PPp' }}
diff --git a/frontend/app/src/app/modules/mail/mail-meta.scss b/frontend/app/src/app/modules/mail/mail-meta.scss deleted file mode 100644 index 6e1f6d69..00000000 --- a/frontend/app/src/app/modules/mail/mail-meta.scss +++ /dev/null @@ -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; -} diff --git a/frontend/app/src/app/modules/mail/mail-page.component.ts b/frontend/app/src/app/modules/mail/mail-page.component.ts index 9b155d31..c4142b09 100644 --- a/frontend/app/src/app/modules/mail/mail-page.component.ts +++ b/frontend/app/src/app/modules/mail/mail-page.component.ts @@ -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, ], }) diff --git a/frontend/app/src/app/modules/mail/mail-page.html b/frontend/app/src/app/modules/mail/mail-page.html index ea43cb5e..943333b8 100644 --- a/frontend/app/src/app/modules/mail/mail-page.html +++ b/frontend/app/src/app/modules/mail/mail-page.html @@ -10,11 +10,11 @@ @if (mails | async; as mails) { @for (mail of mails; track mail) { - +
@if (mail.envelope.from[0]; as from) {
- {{ (from.name || from.address).charAt(0).toUpperCase() }} + {{ (from.name || from.address)?.charAt(0)?.toUpperCase() }}
}
@@ -24,7 +24,13 @@ }

{{ mail.envelope.subject }}

- {{ mail.envelope.date | dfnsParseIso | dfnsFormatPure: 'PPp' }} + + @if (mail.envelope.date | dfnsIsToday) { + {{ mail.envelope.date | dfnsFormatPure: 'p' }} + } @else { + {{ mail.envelope.date | dfnsFormatPure: 'P' }} + } +
}
diff --git a/frontend/app/src/app/modules/mail/mail.service.ts b/frontend/app/src/app/modules/mail/mail.service.ts index 8d3383e5..78905ac2 100644 --- a/frontend/app/src/app/modules/mail/mail.service.ts +++ b/frontend/app/src/app/modules/mail/mail.service.ts @@ -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 { - return this.httpClient.get('http://localhost:4000/', {responseType: 'json'}).pipe( + private listRawEmails(): Observable { + return this.httpClient.get('http://localhost:4000/', {responseType: 'json'}).pipe( + map(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 { - return this.httpClient.get(`http://localhost:4000/${id}/attachment/${attachment ?? 'TEXT'}`, { + private getRawEmail(id: string): Observable { + return this.httpClient + .get(`http://localhost:4000/${id}`, {responseType: 'json'}) + .pipe(mergeMap(it => RawEmail.parseAsync(it))); + } + + private getFullAttachment(id: string | number, attachment: string): Observable { + return this.httpClient.get(`http://localhost:4000/${id}/raw/${attachment}`, { responseType: 'arraybuffer', }); } - getMail(id: string): Observable { - return this.httpClient.get(`http://localhost:4000/${id}`, {responseType: 'json'}); + private getRawAttachment(id: string | number, attachment = ''): Observable { + return this.httpClient.get(`http://localhost:4000/${id}/attachment/${attachment}`, { + responseType: 'arraybuffer', + }); } + + private resolveRawEmail(email: RawEmail): Observable { + console.log(email); + + function value(value: undefined): undefined; + function value(value: T): SignedValue; + function value(value: T | undefined): SignedValue | 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(value: T): SignedValue; + function signed(value: T | undefined): SignedValue | 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, + signature?: Signature, + ): Observable> => { + // 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 { + 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 { + 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), + ); + }*/ } diff --git a/frontend/app/src/app/modules/mail/parts/mail-attachment-text.pipe.ts b/frontend/app/src/app/modules/mail/parts/mail-attachment-text.pipe.ts deleted file mode 100644 index 1760c0df..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-attachment-text.pipe.ts +++ /dev/null @@ -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); - } -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.component.ts b/frontend/app/src/app/modules/mail/parts/mail-part-attachment.component.ts deleted file mode 100644 index ae196561..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.component.ts +++ /dev/null @@ -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; -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.html b/frontend/app/src/app/modules/mail/parts/mail-part-attachment.html deleted file mode 100644 index 7ed688ad..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.html +++ /dev/null @@ -1,9 +0,0 @@ - - - @if (part.parameters?.name) { - {{ part.parameters?.name }} - } - {{ part.size | dataSize }} - - - diff --git a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.scss b/frontend/app/src/app/modules/mail/parts/mail-part-attachment.scss deleted file mode 100644 index 28bbbd12..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part-attachment.scss +++ /dev/null @@ -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; -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-part.component.ts b/frontend/app/src/app/modules/mail/parts/mail-part.component.ts deleted file mode 100644 index 53032d80..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part.component.ts +++ /dev/null @@ -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]); - } - } -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-part.html b/frontend/app/src/app/modules/mail/parts/mail-part.html deleted file mode 100644 index b9fb555f..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part.html +++ /dev/null @@ -1,23 +0,0 @@ -@if (part.type === 'text/html') { - @if (content | async | mailAttachmentText: part.parameters?.charset; as content) { -
- } @else { - - } -} @else if (part.type === 'text/plain') { - @if (content | async | mailAttachmentText: part.parameters?.charset; as content) { -
{{ content }}
- } @else { - - } -} @else if (part.type === 'multipart/alternative') { - @if (part.childNodes && part.childNodes.length > 0) { - - } -} @else if (part.type.startsWith('multipart')) { - @for (child of part.childNodes; track child) { - - } -} @else { - -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-part.scss b/frontend/app/src/app/modules/mail/parts/mail-part.scss deleted file mode 100644 index 404da90e..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-part.scss +++ /dev/null @@ -1,9 +0,0 @@ -.html { - overflow-x: auto; -} - -pre { - font-family: inherit; - word-wrap: break-word; - white-space: pre-wrap; -} diff --git a/frontend/app/src/app/modules/mail/parts/mail-preferred-alternative.pipe.ts b/frontend/app/src/app/modules/mail/parts/mail-preferred-alternative.pipe.ts deleted file mode 100644 index 49b21acd..00000000 --- a/frontend/app/src/app/modules/mail/parts/mail-preferred-alternative.pipe.ts +++ /dev/null @@ -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] - ); - } -} diff --git a/frontend/app/src/app/modules/mail/schema.ts b/frontend/app/src/app/modules/mail/schema.ts new file mode 100644 index 00000000..83c4d074 --- /dev/null +++ b/frontend/app/src/app/modules/mail/schema.ts @@ -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; + +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; + +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 & { + childNodes?: RawEmailBodyStructure[]; +}; + +export const RawEmailBodyStructure: z.ZodType = 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; + +export interface Signature { + type: 'pkcs7'; + valid: boolean; +} + +export interface SignedValue { + 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; + date: SignedValue; + from: SignedValue; + to?: SignedValue[]; + cc?: SignedValue[]; + bcc?: SignedValue[]; + html?: SignedValue; + text?: SignedValue; + attachments: SignedValue[]; +} diff --git a/frontend/app/src/app/modules/mail/types.ts b/frontend/app/src/app/modules/mail/types.ts deleted file mode 100644 index 484cf83d..00000000 --- a/frontend/app/src/app/modules/mail/types.ts +++ /dev/null @@ -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; -} diff --git a/frontend/app/src/app/modules/mail/parts/shadow-html.directive.ts b/frontend/app/src/app/util/shadow-html.directive.ts similarity index 100% rename from frontend/app/src/app/modules/mail/parts/shadow-html.directive.ts rename to frontend/app/src/app/util/shadow-html.directive.ts diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json index 7fe7a217..0970662e 100644 --- a/frontend/app/src/assets/i18n/de.json +++ b/frontend/app/src/assets/i18n/de.json @@ -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", diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json index 013aaac4..57494a63 100644 --- a/frontend/app/src/assets/i18n/en.json +++ b/frontend/app/src/assets/i18n/en.json @@ -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", diff --git a/frontend/app/src/assets/icons.min.woff2 b/frontend/app/src/assets/icons.min.woff2 index ad7ecc10..1ab25045 100644 Binary files a/frontend/app/src/assets/icons.min.woff2 and b/frontend/app/src/assets/icons.min.woff2 differ diff --git a/frontend/app/src/index.html b/frontend/app/src/index.html index cf3ded06..15648190 100644 --- a/frontend/app/src/index.html +++ b/frontend/app/src/index.html @@ -18,7 +18,7 @@ - + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4317be2c..84a49f7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -906,6 +906,9 @@ importers: '@types/dom-view-transitions': specifier: 1.0.4 version: 1.0.4 + asn1js: + specifier: ^3.0.5 + version: 3.0.5 capacitor-secure-storage-plugin: specifier: 0.9.0 version: 0.9.0(@capacitor/core@6.1.1) @@ -930,6 +933,12 @@ importers: jsonpath-plus: specifier: 6.0.1 version: 6.0.1 + libbase64: + specifier: ^1.3.0 + version: 1.3.0 + libqp: + specifier: ^2.1.0 + version: 2.1.0 maplibre-gl: specifier: 4.0.2 version: 4.0.2 @@ -975,6 +984,9 @@ importers: tslib: specifier: 2.6.2 version: 2.6.2 + zod: + specifier: ^3.23.8 + version: 3.23.8 zone.js: specifier: 0.14.4 version: 0.14.4 @@ -1066,6 +1078,9 @@ importers: '@types/glob': specifier: 8.1.0 version: 8.1.0 + '@types/imapflow': + specifier: 1.0.18 + version: 1.0.18 '@types/jasmine': specifier: 5.1.4 version: 5.1.4 @@ -22234,6 +22249,10 @@ packages: resolution: {integrity: sha512-C1x6lfvBICFTQIMgbt3JqMOno3VOtkWat/xEakLTOurskYIHPmzJrzd1e8BnmtdDVJlGuk5D+FxyCA8MPmkIyA==} dev: true + /zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + dev: false + /zone.js@0.14.4: resolution: {integrity: sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==} dependencies: