mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-07 22:12:53 +00:00
feat: mail
This commit is contained in:
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,7 +29,7 @@ p > ion-skeleton-text {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
ion-item.unread h2 {
|
||||
h2.unread {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user