feat: mail

This commit is contained in:
2024-11-06 17:19:53 +01:00
parent 31c54083a9
commit 2d7906f8ee
16 changed files with 458 additions and 158 deletions

View File

@@ -1,7 +1,15 @@
import {HttpClient} from '@angular/common/http';
import {Injectable, inject} from '@angular/core';
import {Observable, map, catchError, tap, mergeMap, forkJoin, of} from 'rxjs';
import {Email, MailboxTreeRoot, RawEmail, RawEmailBodyStructure, Signature, SignedValue} from './schema';
import {
Email,
EmailWithoutBody,
MailboxTreeRoot,
RawEmail,
RawEmailBodyStructure,
Signature,
SignedValue,
} from './schema';
import {ContentInfo, SignedData} from 'pkijs';
import PostalMime from 'postal-mime';
import {z} from 'zod';
@@ -22,15 +30,21 @@ export class MailAdapterService {
request<T>(options: {
method?: string;
path?: string[];
options?: Record<string, string>;
options?: Record<string, string | string[]>;
responseType?: 'json' | 'arraybuffer';
credentials?: string;
}): Observable<T> {
return this.httpClient.request<T>(
options.method ?? 'GET',
`https://cumulet.rz.uni-frankfurt.de/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
`http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
options.options
? `?${Object.entries(options.options).map(item => item.map(encodeURIComponent).join('='))}`
? `?${Object.entries(options.options)
.flatMap(([key, values]) =>
(Array.isArray(values) ? values : [values]).map(
value => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
),
)
.join('&')}`
: ''
}`,
{
@@ -62,22 +76,27 @@ export class MailAdapterService {
return this.request<string[]>({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it)));
}
listRawEmails(credentials: string, mailbox: string, since?: string): Observable<string[]> {
countEmails(credentials: string, mailbox: string, since?: string): Observable<number> {
return this.request<unknown>({
credentials,
path: [mailbox],
options: since === undefined ? undefined : {since},
}).pipe(
mergeMap(it => z.array(z.string()).parseAsync(it)),
tap(console.log),
mergeMap(it => z.object({messages: z.number()}).parseAsync(it)),
map(it => it.messages),
);
}
private getRawEmail(credentials: string, mailbox: string, id: string): Observable<RawEmail> {
private getRawEmail(
credentials: string,
mailbox: string,
id: string,
partial: boolean,
): Observable<RawEmail> {
return this.request<unknown>({
credentials,
path: [mailbox, id],
options: {raw: 'true'},
options: partial ? {raw: 'true', partial: 'true'} : {raw: 'true'},
}).pipe(mergeMap(it => RawEmail.parseAsync(it)));
}
@@ -85,12 +104,7 @@ export class MailAdapterService {
return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'});
}
private getRawPart(
credentials: string,
mailbox: string,
id: string,
part: string,
): Observable<ArrayBuffer> {
getRawPart(credentials: string, mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
return this.request({
path: [mailbox, id, part],
options: {raw: 'true'},
@@ -99,12 +113,14 @@ export class MailAdapterService {
});
}
private resolveRawEmail(credentials: string, mailbox: string, email: RawEmail): Observable<Email> {
console.log(email);
private resolveRawEmail(
credentials: string,
mailbox: string,
email: RawEmail,
): Observable<Email | EmailWithoutBody> {
if (
email.bodyStructure.type === 'application/x-pkcs7-mime' ||
email.bodyStructure.type === 'application/pkcs7-mime'
email.bodyStructure?.type === 'application/x-pkcs7-mime' ||
email.bodyStructure?.type === 'application/pkcs7-mime'
) {
return this.getRawPart(credentials, mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
mergeMap(async buffer => {
@@ -190,7 +206,7 @@ export class MailAdapterService {
} else if (item.type === 'text/plain') {
return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe(
map(text => {
result.html = {value: new TextDecoder().decode(text), signature};
result.text = {value: new TextDecoder().decode(text), signature};
return result;
}),
);
@@ -204,34 +220,113 @@ export class MailAdapterService {
} else if (item.part === undefined) {
return of(result);
} else {
result.attachments.push({value: {part: item.part, size: item.size ?? Number.NaN, filename: ''}});
result.attachments.push({
value: {
part: item.part,
size: item.size ?? Number.NaN,
filename: item.parameters?.['name'] ?? item.part,
},
});
return of(result);
}
};
return traverse(email.bodyStructure, {attachments: []}).pipe(
map(
partial =>
({
...partial,
id: email.seq,
mailbox,
flags: new Set<string>(email.flags),
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),
}) satisfies Email,
const emailWithoutBody: Omit<EmailWithoutBody, 'partial'> = {
id: email.seq,
mailbox,
flags: new Set<string>(email.flags),
subject: value(email.envelope.subject),
from: email.envelope.from[0]
? value({
name: email.envelope.from[0].name,
address: email.envelope.from[0].address,
})
: value({
name: email.envelope.sender[0].name,
address: email.envelope.sender[0].address,
}),
to: email.envelope.to?.map(({name, address}) =>
value({
name,
address,
}),
),
tap(console.log),
);
cc: email.envelope.cc?.map(({name, address}) =>
value({
name,
address,
}),
),
bcc: email.envelope.bcc?.map(({name, address}) =>
value({
name,
address,
}),
),
date: value(email.envelope.date),
};
return email.bodyStructure === undefined
? of({...emailWithoutBody, partial: true})
: traverse(email.bodyStructure, {attachments: []}).pipe(
map(
partial =>
({
...partial,
...emailWithoutBody,
}) satisfies Email,
),
tap(console.log),
);
}
getEmail(credentials: string, mailbox: string, id: string): Observable<Email> {
return this.getRawEmail(credentials, mailbox, id).pipe(
getEmail(
credentials: string,
mailbox: string,
id: string,
partial: boolean,
): Observable<Email | EmailWithoutBody> {
return this.getRawEmail(credentials, mailbox, id, partial).pipe(
mergeMap(it => this.resolveRawEmail(credentials, mailbox, it)),
);
}
addFlags(credentials: string, mailbox: string, id: string, flags: string | string[]): Observable<boolean> {
return Array.isArray(flags) && flags.length === 0
? of(true)
: this.request<boolean>({
credentials,
method: 'POST',
path: [mailbox, id],
options: {
flags: flags,
},
});
}
removeFlags(
credentials: string,
mailbox: string,
id: string,
flags: string | string[],
): Observable<boolean> {
return Array.isArray(flags) && flags.length === 0
? of(true)
: this.request<boolean>({
credentials,
method: 'DELETE',
path: [mailbox, id],
options: {
flags: flags,
},
});
}
deleteEmail(credentials: string, mailbox: string, id: string): Observable<boolean> {
return this.request<boolean>({
credentials,
method: 'DELETE',
path: [mailbox, id],
});
}
}

View File

@@ -1,17 +1,21 @@
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {IonicModule} from '@ionic/angular';
import {IonRouterOutlet, 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 {map, mergeMap} from 'rxjs';
import {firstValueFrom, map, mergeMap, take} from 'rxjs';
import {DomSanitizer} from '@angular/platform-browser';
import {materialFade} from 'src/app/animation/material-motion';
import {TranslateModule} from '@ngx-translate/core';
import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
import {MailStorageProvider} from './mail-storage.provider';
import {DataSizePipe} from '../../util/data-size.pipe';
import {EmailAttachment, EmailAttachmentRemote} from './schema';
import {MailService} from './mail.service';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
@Component({
selector: 'stapps-mail-detail',
@@ -32,6 +36,7 @@ import {MailStorageProvider} from './mail-storage.provider';
ShadowHtmlDirective,
TranslateModule,
TitleCasePipe,
DataSizePipe,
],
})
export class MailDetailComponent {
@@ -39,8 +44,12 @@ export class MailDetailComponent {
readonly mailStorage = inject(MailStorageProvider);
readonly mailService = inject(MailService);
readonly sanitizer = inject(DomSanitizer);
readonly router = inject(IonRouterOutlet);
parameters = this.activatedRoute.paramMap.pipe(
map(parameters => ({
mailbox: parameters.get('mailbox')!,
@@ -52,7 +61,42 @@ export class MailDetailComponent {
collapse = signal(false);
todo() {
alert('TODO');
constructor() {
this.mail.pipe(take(1), takeUntilDestroyed()).subscribe(mail => {
this.mailStorage.addFlags(mail, ['\\Seen']);
});
}
async markUnread() {
await this.mailStorage.removeFlags(await firstValueFrom(this.mail), ['\\Seen']);
await this.router.pop();
}
async delete() {
this.mailStorage.deleteEmail(await firstValueFrom(this.mail));
await this.router.pop();
}
async downloadAttachment(attachment: EmailAttachment) {
const data = await firstValueFrom(
this.mail.pipe(
take(1),
mergeMap(mail =>
this.mailService.downloadAttachment(
mail.mailbox,
mail.id,
(attachment as EmailAttachmentRemote).part,
),
),
),
);
const url = URL.createObjectURL(new Blob([data], {}));
const a = document.createElement('a');
a.href = url;
a.download = attachment.filename;
a.click();
URL.revokeObjectURL(url);
a.remove();
}
}

View File

@@ -14,10 +14,10 @@
}
</ion-title>
<ion-buttons slot="end">
<ion-button (click)="todo()">
<ion-button (click)="delete()">
<ion-icon slot="icon-only" name="delete"></ion-icon>
</ion-button>
<ion-button (click)="todo()">
<ion-button (click)="markUnread()">
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
</ion-button>
</ion-buttons>
@@ -28,16 +28,21 @@
@if (mail | async; as mail) {
<h1 @materialFade>{{ mail.subject?.value }}</h1>
<aside @materialFade>
<strong class="from">
{{ mail.from.value.name || mail.from.value.address }}
<div class="from">
@if (mail.from.value.name) {
<strong>
{{ mail.from.value.name }}
</strong>
}
{{ 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>
<div class="to">
to
<strong>to</strong>
@for (to of mail.to; track to) {
<span>{{ to.value.name || to.value.address }}</span>
}
@@ -50,6 +55,10 @@
}
</div>
}
@if (mail.date) {
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
}
</aside>
@if (mail.html) {
<main @materialFade>
@@ -60,34 +69,13 @@
<pre>{{ mail.text.value }}</pre>
</main>
}
<ion-list>
<div class="attachments">
@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-button class="attachment" fill="outline" (click)="downloadAttachment(attachment.value)">
<ion-icon slot="start" name="download"></ion-icon>
<ion-label>{{ attachment.value.filename }} ({{ attachment.value.size | dataSize }})</ion-label>
</ion-button>
}
</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>
</div>
}
</ion-content>

View File

@@ -49,6 +49,26 @@ main {
background: var(--ion-item-background);
}
ion-list {
background: none;
}
ion-card {
ion-card-header {
padding-block: var(--spacing-sm);
padding-inline: var(--spacing-sm);
ion-card-title {
font-size: 1rem;
}
}
ion-card-content {
padding-block: 0;
padding-inline: 0;
}
}
.html {
overflow-x: auto;
}
@@ -80,16 +100,17 @@ footer {
}
}
.attachment {
.attachments {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--spacing-md);
margin: var(--spacing-md) 0;
padding: var(--spacing-md);
margin-block-end: var(--spacing-xl);
margin-inline: var(--spacing-md);
}
border: 1px solid var(--ion-border-color);
border-radius: var(--border-radius-default);
.attachment[fill='outline']::part(native) {
border: 1px solid currentcolor;
}
ion-content::part(background) {

View File

@@ -21,7 +21,7 @@ import {
firstValueFrom,
Subject,
} from 'rxjs';
import {Email, EmailMeta, MailboxTreeRoot} from './schema';
import {Email, EmailMeta, EmailWithoutBody, MailboxTreeRoot} from './schema';
import equal from 'fast-deep-equal';
import {MailAdapterService} from './mail-adapter.service';
import {StorageProvider} from '../storage/storage.provider';
@@ -147,7 +147,9 @@ export class MailStorageProvider {
const request = index.getAll(IDBKeyRange.only(mailbox));
return merge(
fromEvent(request, 'success').pipe(map(() => request.result as Array<EmailMeta | Email>)),
fromEvent(request, 'success').pipe(
map(() => request.result as Array<EmailMeta | EmailWithoutBody | Email>),
),
fromEvent(request, 'error').pipe(
map(event => {
throw (event.target as IDBRequest).error;
@@ -156,7 +158,7 @@ export class MailStorageProvider {
).pipe(take(1));
});
}),
map<Array<Email | EmailMeta>, EmailMeta[]>(emails =>
map<Array<Email | EmailWithoutBody | EmailMeta>, EmailMeta[]>(emails =>
emails
.map(email => ({id: email.id, mailbox: email.mailbox, incomplete: true}) satisfies EmailMeta)
.sort((a, b) => Number(b.id) - Number(a.id)),
@@ -165,7 +167,10 @@ export class MailStorageProvider {
);
}
getEmail(mailbox: string, id: string): Observable<Email> {
getEmail(mailbox: string, id: string): Observable<Email>;
getEmail(mailbox: string, id: string, partial: false): Observable<Email>;
getEmail(mailbox: string, id: string, partial: boolean): Observable<Email | EmailWithoutBody>;
getEmail(mailbox: string, id: string, partial = false): Observable<Email | EmailWithoutBody> {
return this.emailChanged.pipe(
filter(it => it.has(JSON.stringify([id, mailbox]))),
startWith(undefined),
@@ -188,16 +193,14 @@ export class MailStorageProvider {
});
}),
mergeMap(email =>
'incomplete' in email
'incomplete' in email || (!partial && 'partial' in email)
? this.credentials.pipe(
filter(it => it !== undefined),
take(1),
mergeMap(credentials =>
this.mailAdapter.getEmail(credentials!, mailbox, id).pipe(
this.mailAdapter.getEmail(credentials!, mailbox, id, partial).pipe(
mergeMap(async email => {
console.log('fetiching');
await this.setEmail(email, true);
console.log('done');
return email;
}),
),
@@ -209,7 +212,10 @@ export class MailStorageProvider {
);
}
async setEmail(email: Email | EmailMeta | Array<Email | EmailMeta>, quiet = false): Promise<void> {
async setEmail(
email: Email | EmailMeta | EmailWithoutBody | Array<Email | EmailMeta | EmailWithoutBody>,
quiet = false,
): Promise<void> {
const database = await firstValueFrom(this.database);
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
@@ -240,4 +246,64 @@ export class MailStorageProvider {
),
);
}
async addFlags(email: Email | EmailWithoutBody, flags: string[]): Promise<boolean> {
let hasChanged = false;
for (const flag of flags) {
if (!email.flags.has(flag)) {
hasChanged = true;
email.flags.add(flag);
}
}
if (hasChanged) {
const credentials = await firstValueFrom(this.credentials);
await this.setEmail(email);
return firstValueFrom(this.mailAdapter.addFlags(credentials!, email.mailbox, email.id, flags));
} else {
return false;
}
}
async removeFlags(email: Email | EmailWithoutBody, flags: string[]): Promise<boolean> {
let hasChanged = false;
for (const flag of flags) {
if (email.flags.has(flag)) {
hasChanged = true;
email.flags.delete(flag);
}
}
if (hasChanged) {
const credentials = await firstValueFrom(this.credentials);
await this.setEmail(email);
return firstValueFrom(this.mailAdapter.removeFlags(credentials!, email.mailbox, email.id, flags));
} else {
return false;
}
}
async deleteEmail(email: EmailMeta | Email | EmailWithoutBody): Promise<boolean> {
const credentials = await firstValueFrom(this.credentials);
const result = await firstValueFrom(this.mailAdapter.deleteEmail(credentials!, email.mailbox, email.id));
const database = await firstValueFrom(this.database);
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
store.delete([email.id, email.mailbox]);
return firstValueFrom(
merge(
fromEvent(transaction, 'complete').pipe(
map(() => {
this.mailboxContentChanged.next(new Set([email.mailbox]));
return result;
}),
),
fromEvent(transaction, 'error').pipe(
map(event => {
throw (event.target as IDBRequest).error;
}),
),
),
);
}
}

View File

@@ -17,6 +17,9 @@ import {NgModule, inject} from '@angular/core';
import {MailService} from './mail.service';
import {map, take} from 'rxjs';
/**
*
*/
function mailLoginGuard() {
const router = inject(Router);
return inject(MailService).isLoggedIn.pipe(

View File

@@ -1,7 +1,7 @@
import {Pipe, PipeTransform, inject} from '@angular/core';
import {Observable} from 'rxjs';
import {MailStorageProvider} from './mail-storage.provider';
import {Email} from './schema';
import {Email, EmailWithoutBody} from './schema';
@Pipe({
name: 'mail',
@@ -12,6 +12,19 @@ export class MailPipe implements PipeTransform {
mailStorage = inject(MailStorageProvider);
transform(value: {mailbox: string; id: string}): Observable<Email> {
return this.mailStorage.getEmail(value.mailbox, value.id);
return this.mailStorage.getEmail(value.mailbox, value.id, false);
}
}
@Pipe({
name: 'partialMail',
pure: true,
standalone: true,
})
export class PartialMailPipe implements PipeTransform {
mailStorage = inject(MailStorageProvider);
transform(value: {mailbox: string; id: string}): Observable<EmailWithoutBody | Email> {
return this.mailStorage.getEmail(value.mailbox, value.id, true);
}
}

View File

@@ -10,6 +10,7 @@ import {
of,
catchError,
BehaviorSubject,
take,
} from 'rxjs';
import {MailStorageProvider} from './mail-storage.provider';
import {MailAdapterService} from './mail-adapter.service';
@@ -48,10 +49,15 @@ export class MailService {
mergeMap(([credentials, mailboxes]) =>
from(mailboxes.folders).pipe(
mergeMap(async mailbox => {
return this.mailAdapter.listRawEmails(credentials!, mailbox.path).pipe(
map<string[], EmailMeta[]>(emails =>
emails.map(it => ({id: it, mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta),
return this.mailAdapter.countEmails(credentials!, mailbox.path).pipe(
map<number, EmailMeta[]>(count =>
Array.from(
{length: count},
(_, i) =>
({id: (i + 1).toString(), mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta,
),
),
tap(emails => console.log(emails)),
catchError(error => {
console.error(error);
return of();
@@ -81,4 +87,12 @@ export class MailService {
this.mailStorage.setCredentials(undefined);
this.mailStorage.setMailboxes(undefined);
}
downloadAttachment(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
return this.mailStorage.credentials.pipe(
filter(it => it !== undefined),
take(1),
mergeMap(credentials => this.mailAdapter.getRawPart(credentials!, mailbox, id, part)),
);
}
}

View File

@@ -8,7 +8,7 @@ import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {TranslateModule} from '@ngx-translate/core';
import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {LazyComponent} from 'src/app/util/lazy.component';
import {MailPipe} from './mail.pipe';
import {PartialMailPipe} from './mail.pipe';
import {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
@@ -24,7 +24,7 @@ import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
TranslateModule,
AsyncPipe,
LazyComponent,
MailPipe,
PartialMailPipe,
IsTodayPipeModule,
FormatPurePipeModule,
RouterModule,

View File

@@ -15,7 +15,7 @@
<ion-list>
@for (mail of mails; track mail.id) {
<ion-item #item [routerLink]="['/mail', mail.mailbox, mail.id]">
@if (mail | mail | lazyLoad: item | async; as mail) {
@if (mail | partialMail | lazyLoad: item | async; as mail) {
<div slot="start" class="avatar">
@if (mail.from; as from) {
<div>

View File

@@ -29,7 +29,7 @@ p > ion-skeleton-text {
border-radius: 50%;
}
ion-item.unread h2 {
h2.unread {
font-weight: bold;
}

View File

@@ -42,7 +42,7 @@ export const RawEmailBodyStructure: z.ZodType<RawEmailBodyStructure> = RawEmailB
});
export const RawEmail = z.object({
bodyStructure: RawEmailBodyStructure,
bodyStructure: z.optional(RawEmailBodyStructure),
labels: z.array(z.string()).transform(it => new Set(it)),
flags: z.array(z.string()).transform(it => new Set(it)),
envelope: RawEmailEnvelope,
@@ -125,4 +125,6 @@ export interface Email {
attachments: SignedValue<EmailAttachment>[];
}
export type EmailWithoutBody = Omit<Email, 'html' | 'text' | 'attachments'> & {partial: true};
export type EmailMeta = Pick<Email, 'id' | 'mailbox'> & {incomplete: true};