feat: mail

This commit is contained in:
2024-09-25 17:02:02 +02:00
committed by Thea Schöbl
parent f81690e19f
commit bc4c45ea21
13 changed files with 390 additions and 228 deletions

View File

@@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
import {MailService} from './mail.service';
import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
@@ -7,7 +7,7 @@ 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} from 'rxjs';
import {map, mergeMap} from 'rxjs';
import {DomSanitizer} from '@angular/platform-browser';
import {materialFade} from 'src/app/animation/material-motion';
import {TranslateModule} from '@ngx-translate/core';
@@ -35,16 +35,23 @@ import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
],
})
export class MailDetailComponent {
mail = this.activatedRoute.params.pipe(mergeMap(({id}) => this.mailService.getEmail(id)));
readonly activatedRoute = inject(ActivatedRoute);
readonly mailService = inject(MailService);
readonly sanitizer = inject(DomSanitizer);
parameters = this.activatedRoute.paramMap.pipe(
map(parameters => ({
mailbox: parameters.get('mailbox')!,
id: parameters.get('id')!,
})),
);
mail = this.parameters.pipe(mergeMap(({mailbox, id}) => this.mailService.getEmail(mailbox, id)));
collapse = signal(false);
constructor(
readonly mailService: MailService,
readonly activatedRoute: ActivatedRoute,
readonly sanitizer: DomSanitizer,
) {}
todo() {
alert('TODO');
}

View File

@@ -0,0 +1,10 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
@Component({
selector: 'stapps-mail-login',
templateUrl: 'mail-login.html',
styleUrl: 'mail-login.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class MailLoginComponent {}

View File

@@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {MailService} from './mail.service';
import {AsyncPipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
@@ -6,7 +6,8 @@ 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, IsTodayPipeModule} from 'ngx-date-fns';
import {RouterModule} from '@angular/router';
import {ActivatedRoute, RouterModule} from '@angular/router';
import {combineLatest, map, mergeMap} from 'rxjs';
@Component({
selector: 'stapps-mail-page',
@@ -26,7 +27,19 @@ import {RouterModule} from '@angular/router';
],
})
export class MailPageComponent {
mails = this.mailService.list();
readonly activatedRoute = inject(ActivatedRoute);
constructor(readonly mailService: MailService) {}
readonly mailService = inject(MailService);
mailbox = this.activatedRoute.paramMap.pipe(map(parameters => parameters.get('mailbox')!));
mails = this.mailbox.pipe(
mergeMap(mailbox =>
this.mailService
.listEmails(mailbox)
.pipe(
mergeMap(emails => combineLatest(emails.map(email => this.mailService.getEmail(mailbox, email)))),
),
),
);
}

View File

@@ -10,25 +10,28 @@
@if (mails | async; as mails) {
<ion-list>
@for (mail of mails; track mail) {
<ion-item [routerLink]="['/mail', mail.seq]" [class.unread]="!mail.flags.has('\\Seen')">
<ion-item
[routerLink]="['/mail', mailbox | async, mail.id]"
[class.unread]="!mail.flags.has('\\Seen')"
>
<div slot="start" class="avatar">
@if (mail.envelope.from[0]; as from) {
@if (mail.from; as from) {
<div>
{{ (from.name || from.address)?.charAt(0)?.toUpperCase() }}
{{ (from.value.name || from.value.address)?.charAt(0)?.toUpperCase() }}
</div>
}
</div>
<ion-label>
@for (from of mail.envelope.from; track from) {
<h2>{{ from.name || from.address }}</h2>
<h2>{{ mail.from.value.name || mail.from.value.address }}</h2>
@if (mail.subject) {
<p>{{ mail.subject.value }}</p>
}
<p>{{ mail.envelope.subject }}</p>
</ion-label>
<ion-note slot="end">
@if (mail.envelope.date | dfnsIsToday) {
{{ mail.envelope.date | dfnsFormatPure: 'p' }}
@if (mail.date.value | dfnsIsToday) {
{{ mail.date.value | dfnsFormatPure: 'p' }}
} @else {
{{ mail.envelope.date | dfnsFormatPure: 'P' }}
{{ mail.date.value | dfnsFormatPure: 'P' }}
}
</ion-note>
</ion-item>

View File

@@ -0,0 +1,29 @@
import {Injectable} from '@angular/core';
import {StorageProvider} from '../storage/storage.provider';
import {Email} from 'postal-mime';
@Injectable({providedIn: 'root'})
export class MailStorageProvider {
constructor(readonly storageProvider: StorageProvider) {}
private storageKey(...path: string[]): string {
return ['mail', ...path.map(encodeURIComponent)].join('/');
}
async get(mailbox: string, id: string): Promise<Email | undefined> {
try {
return await this.storageProvider.get<Email>(this.storageKey(mailbox, id));
} catch (error) {
console.info('Mail not found in storage', error);
return undefined;
}
}
async put(mailbox: string, id: string, email: Email): Promise<void> {
await this.storageProvider.put(this.storageKey(mailbox, id), email);
}
async delete(mailbox: string, id: string): Promise<void> {
await this.storageProvider.delete(this.storageKey(mailbox, id));
}
}

View File

@@ -12,16 +12,36 @@
* 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';
import {RouterModule, UrlSegment, UrlSegmentGroup, UrlTree} from '@angular/router';
import {NgModule, inject} from '@angular/core';
import {MailService} from './mail.service';
@NgModule({
imports: [
RouterModule.forChild([
{path: 'mail', component: MailPageComponent},
{path: 'mail/:id', component: MailDetailComponent},
{
path: 'mail',
loadComponent: () => import('./mail-login.component').then(m => m.MailLoginComponent),
canActivateChild: [
() => {
if (inject(MailService).isLoggedIn()) {
return true;
} else {
return new UrlTree(new UrlSegmentGroup([new UrlSegment('/mail', {})], {}));
}
},
],
children: [
{
path: ':mailbox',
loadComponent: () => import('./mail-page.component').then(m => m.MailPageComponent),
},
{
path: ':mailbox/:id',
loadComponent: () => import('./mail-detail.component').then(m => m.MailDetailComponent),
},
],
},
]),
],
})

View File

@@ -1,66 +1,86 @@
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable, map, tap, mergeMap, of, merge, forkJoin, catchError, mergeScan} from 'rxjs';
import {Observable, map, tap, mergeMap, of, forkJoin, catchError} from 'rxjs';
import PostalMime from 'postal-mime';
import {ContentInfo, SignedData} from 'pkijs';
import {
RawEmail,
Email,
SignedValue,
RawEmailBodyStructure,
EmailAttachment,
EmailAttachmentRemote,
Signature,
} from './schema';
import {RawEmail, Email, SignedValue, RawEmailBodyStructure, Signature} from './schema';
import {z} from 'zod';
import {compareAsc} from 'date-fns';
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};
}
@Injectable({providedIn: 'root'})
export class MailService {
constructor(private httpClient: HttpClient) {}
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))),
private request<T>(options: {
method?: string;
path?: string[];
options?: Record<string, string>;
responseType?: 'json' | 'arraybuffer';
}): Observable<T> {
return this.httpClient.request<T>(
options.method ?? 'GET',
`http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
options.options
? `?${Object.entries(options.options).map(item => item.map(encodeURIComponent).join('='))}`
: ''
}`,
{
responseType: options.responseType as 'json',
headers: {authorization: `Basic ${btoa('test:123')}`},
},
);
}
isLoggedIn() {
return false;
}
listMailboxes(): Observable<string[]> {
return this.request<string[]>({});
}
private listRawEmails(mailbox: string, since?: Date): Observable<string[]> {
return this.request<unknown>({
path: [mailbox],
options: since !== undefined ? {since: since.valueOf().toString()} : undefined,
}).pipe(
mergeMap(it => z.array(z.string()).parseAsync(it)),
tap(console.log),
);
}
private getRawEmail(id: string): Observable<RawEmail> {
return this.httpClient
.get(`http://localhost:4000/${id}`, {responseType: 'json'})
.pipe(mergeMap(it => RawEmail.parseAsync(it)));
private getRawEmail(mailbox: string, id: string): Observable<RawEmail> {
return this.request<unknown>({
path: [mailbox, id],
options: {raw: 'true'},
}).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}`, {
private getPart(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
return this.request<ArrayBuffer>({path: [mailbox, id, part], responseType: 'arraybuffer'});
}
private getRawPart(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
return this.request({
path: [mailbox, id, part],
options: {raw: 'true'},
responseType: 'arraybuffer',
});
}
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> {
private resolveRawEmail(mailbox: string, 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(
return this.getRawPart(mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
mergeMap(async buffer => {
const info = ContentInfo.fromBER(buffer);
const signedData = new SignedData({schema: info.content});
@@ -87,7 +107,9 @@ export class MailService {
}
const result: Email = {
id: email.seq,
subject: signedEmail.subject ? signed(signedEmail.subject) : value(email.envelope.subject),
flags: new Set<string>(), //TODO
from: signed({
name: signedEmail.from.name || undefined,
address: signedEmail.from.address || undefined,
@@ -117,8 +139,8 @@ export class MailService {
// 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!),
data: this.getPart(mailbox, email.seq, item.childNodes![0].part!),
signature: this.getRawPart(mailbox, email.seq, item.childNodes![1].part!),
}).pipe(
mergeMap(({data, signature}) => {
const info = ContentInfo.fromBER(signature);
@@ -136,23 +158,23 @@ export class MailService {
map(children => children[0]),
);
} else if (item.type === 'text/plain') {
return this.getRawAttachment(email.seq, item.part ?? 'TEXT').pipe(
return this.getRawPart(mailbox, 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(
return this.getRawPart(mailbox, 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: ''}});
} else if (item.part === undefined) {
return of(result);
} else {
result.attachments.push({value: {part: item.part, size: item.size ?? Number.NaN, filename: ''}});
return of(result);
}
};
@@ -171,87 +193,14 @@ export class MailService {
);
}
getEmail(id: string): Observable<Email> {
return this.getRawEmail(id).pipe(
mergeMap(it => this.resolveRawEmail(it)),
getEmail(mailbox: string, id: string): Observable<Email> {
return this.getRawEmail(mailbox, id).pipe(
mergeMap(it => this.resolveRawEmail(mailbox, it)),
tap(console.log),
);
}
list() {
return this.listRawEmails().pipe(
tap(it => {
const email = it[7];
console.log(email);
}),
);
listEmails(mailbox: string): Observable<string[]> {
return this.listRawEmails(mailbox);
}
/*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),
);
}*/
}

View File

@@ -82,6 +82,8 @@ export interface EmailAttachmentLocal extends EmailAttachmentBase {
export type EmailAttachment = EmailAttachmentRemote | EmailAttachmentLocal;
export interface Email {
id: string;
flags: Set<string>;
subject?: SignedValue<string>;
date: SignedValue<Date>;
from: SignedValue<EmailAddress>;