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

@@ -29,6 +29,7 @@
"@openstapps/core-tools": "workspace:*", "@openstapps/core-tools": "workspace:*",
"@openstapps/logger": "workspace:*", "@openstapps/logger": "workspace:*",
"commander": "10.0.0", "commander": "10.0.0",
"cors": "2.8.5",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"express": "4.18.2", "express": "4.18.2",
"imapflow": "1.0.162", "imapflow": "1.0.162",
@@ -41,6 +42,7 @@
"@openstapps/eslint-config": "workspace:*", "@openstapps/eslint-config": "workspace:*",
"@openstapps/prettier-config": "workspace:*", "@openstapps/prettier-config": "workspace:*",
"@openstapps/tsconfig": "workspace:*", "@openstapps/tsconfig": "workspace:*",
"@types/cors": "2.8.13",
"@types/express": "4.17.17", "@types/express": "4.17.17",
"@types/imapflow": "1.0.18", "@types/imapflow": "1.0.18",
"@types/mailparser": "3.4.4", "@types/mailparser": "3.4.4",

View File

@@ -1,36 +1,74 @@
import {config} from 'dotenv'; import {config} from 'dotenv';
import {ImapFlow} from 'imapflow'; import {ImapFlow} from 'imapflow';
import {Logger} from '@openstapps/logger';
import {createHash} from 'node:crypto';
import express from 'express'; import express from 'express';
import cors from 'cors';
config({path: '.env.local'}); config({path: '.env.local'});
const app = express(); const app = express();
const port = process.env.PORT || 4000; const port = process.env.PORT || 4000;
const maxClientAge = 10_000; // 10 seconds
const clients = new Map<string, {destroyRef: NodeJS.Timeout; client: Promise<ImapFlow>}>();
/**
*
*/
async function destroyClient(clientUid: string) {
const client = clients.get(clientUid);
if (!client) return;
clients.delete(clientUid);
clearTimeout(client.destroyRef);
try {
await client.client.then(it => it.logout());
} catch (error) {
await Logger.error(error);
}
}
app.use(cors());
app.use(async (request, response, next) => { app.use(async (request, response, next) => {
try { try {
const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64') const authorization = request.headers['authorization'];
.toString('utf8') if (!authorization) {
.split(':'); response.status(401).send();
return;
}
const client = new ImapFlow({ const clientUid = createHash('sha256').update(authorization).digest('hex');
host: 'imap.server.uni-frankfurt.de',
port: 993,
secure: true,
emitLogs: false,
auth: {user, pass},
});
response.locals.client = client;
await client.connect(); let client = clients.get(clientUid);
response.on('finish', async () => { if (client === undefined) {
await client.logout(); const [user, pass] = Buffer.from(authorization.replace(/^Basic /, ''), 'base64')
client.close(); .toString('utf8')
}); .split(':');
const imapClient = new ImapFlow({
host: 'imap.server.uni-frankfurt.de',
port: 993,
secure: true,
emitLogs: false,
auth: {user, pass},
});
client = {
destroyRef: undefined as unknown as NodeJS.Timeout,
client: imapClient.connect().then(() => imapClient),
};
clients.set(clientUid, client);
}
clearTimeout(client.destroyRef);
client.destroyRef = setTimeout(() => destroyClient(clientUid), maxClientAge);
response.locals.client = await client.client;
next(); next();
} catch { } catch (error) {
response.status(401).send(); await Logger.error(error);
response.status(500).send();
} }
}); });
@@ -41,52 +79,67 @@ app.get('/', async (_request, response) => {
app.get('/:mailbox', async (request, response) => { app.get('/:mailbox', async (request, response) => {
try { try {
await response.locals.client.mailboxOpen(request.params.mailbox);
const since = Number(request.query.since) || undefined;
const preData = await response.locals.client.status(request.params.mailbox, {messages: true}); const preData = await response.locals.client.status(request.params.mailbox, {messages: true});
if (preData.messages === 0) { response.json({messages: preData.messages});
response.json([]);
return;
}
const data = response.locals.client.fetch(
'1:*',
{},
{
// caution, BigInt can throw
changedSince: typeof since === 'string' ? BigInt(since) : undefined,
},
);
const messages = [];
for await (const message of data) {
messages.push(message.seq.toString());
}
response.json(messages);
} catch (error) { } catch (error) {
console.error(error); await Logger.error(error);
response.status(404).send(); response.status(404).send();
} }
}); });
app.get('/:mailbox/:id', async (request, response) => { app.get('/:mailbox/:id', async (request, response) => {
try { try {
await response.locals.client.mailboxOpen(request.params.mailbox); await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: true});
const message = await response.locals.client.fetchOne(request.params.id, { const message = await response.locals.client.fetchOne(request.params.id, {
envelope: true, envelope: true,
labels: true, labels: true,
flags: true, flags: true,
bodyStructure: true, bodyStructure: request.query.partial ? false : true,
}); });
response.json({ response.json({
bodyStructure: message.bodyStructure, bodyStructure: request.query.partial ? undefined : message.bodyStructure,
labels: [...(message.labels ?? [])], labels: [...(message.labels ?? [])],
flags: [...(message.flags ?? [])], flags: [...(message.flags ?? [])],
envelope: message.envelope, envelope: message.envelope,
seq: message.seq, seq: message.seq,
}); });
} catch (error) { } catch (error) {
console.error(error); await Logger.error(error);
response.status(404).send();
}
});
/**
*
*/
function parseFlags(query: Record<string, unknown>): string[] {
const rawFlags = query['flags'] ?? [];
const flagArray = Array.isArray(rawFlags) ? rawFlags : [rawFlags];
return flagArray.filter(it => typeof it === 'string');
}
app.post('/:mailbox/:id', async (request, response) => {
try {
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: false});
response.json(await response.locals.client.messageFlagsAdd(request.params.id, parseFlags(request.query)));
} catch (error) {
await Logger.error(error);
response.status(404).send();
}
});
app.delete('/:mailbox/:id', async (request, response) => {
try {
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: false});
if ('flags' in request.query) {
response.json(
await response.locals.client.messageFlagsRemove(request.params.id, parseFlags(request.query)),
);
} else {
response.json(await response.locals.client.messageDelete(request.params.id));
}
} catch (error) {
await Logger.error(error);
response.status(404).send(); response.status(404).send();
} }
}); });
@@ -113,11 +166,11 @@ app.get('/:mailbox/:id/:part', async (request, response) => {
}); });
} }
} catch (error) { } catch (error) {
console.error(error); await Logger.error(error);
response.status(404).send(); response.status(404).send();
} }
}); });
app.listen(port, () => { app.listen(port, () => {
console.log(`Server listening on port ${port}`); Logger.info(`Server listening on port ${port}`);
}); });

View File

@@ -1,7 +1,15 @@
import {HttpClient} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Injectable, inject} from '@angular/core'; import {Injectable, inject} from '@angular/core';
import {Observable, map, catchError, tap, mergeMap, forkJoin, of} from 'rxjs'; 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 {ContentInfo, SignedData} from 'pkijs';
import PostalMime from 'postal-mime'; import PostalMime from 'postal-mime';
import {z} from 'zod'; import {z} from 'zod';
@@ -22,15 +30,21 @@ export class MailAdapterService {
request<T>(options: { request<T>(options: {
method?: string; method?: string;
path?: string[]; path?: string[];
options?: Record<string, string>; options?: Record<string, string | string[]>;
responseType?: 'json' | 'arraybuffer'; responseType?: 'json' | 'arraybuffer';
credentials?: string; credentials?: string;
}): Observable<T> { }): Observable<T> {
return this.httpClient.request<T>( return this.httpClient.request<T>(
options.method ?? 'GET', options.method ?? 'GET',
`https://cumulet.rz.uni-frankfurt.de/${options.path?.map(encodeURIComponent).join('/') ?? ''}${ `http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
options.options 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))); 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>({ return this.request<unknown>({
credentials, credentials,
path: [mailbox], path: [mailbox],
options: since === undefined ? undefined : {since}, options: since === undefined ? undefined : {since},
}).pipe( }).pipe(
mergeMap(it => z.array(z.string()).parseAsync(it)), mergeMap(it => z.object({messages: z.number()}).parseAsync(it)),
tap(console.log), 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>({ return this.request<unknown>({
credentials, credentials,
path: [mailbox, id], path: [mailbox, id],
options: {raw: 'true'}, options: partial ? {raw: 'true', partial: 'true'} : {raw: 'true'},
}).pipe(mergeMap(it => RawEmail.parseAsync(it))); }).pipe(mergeMap(it => RawEmail.parseAsync(it)));
} }
@@ -85,12 +104,7 @@ export class MailAdapterService {
return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'}); return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'});
} }
private getRawPart( getRawPart(credentials: string, mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
credentials: string,
mailbox: string,
id: string,
part: string,
): Observable<ArrayBuffer> {
return this.request({ return this.request({
path: [mailbox, id, part], path: [mailbox, id, part],
options: {raw: 'true'}, options: {raw: 'true'},
@@ -99,12 +113,14 @@ export class MailAdapterService {
}); });
} }
private resolveRawEmail(credentials: string, mailbox: string, email: RawEmail): Observable<Email> { private resolveRawEmail(
console.log(email); credentials: string,
mailbox: string,
email: RawEmail,
): Observable<Email | EmailWithoutBody> {
if ( if (
email.bodyStructure.type === 'application/x-pkcs7-mime' || email.bodyStructure?.type === 'application/x-pkcs7-mime' ||
email.bodyStructure.type === 'application/pkcs7-mime' email.bodyStructure?.type === 'application/pkcs7-mime'
) { ) {
return this.getRawPart(credentials, mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe( return this.getRawPart(credentials, mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
mergeMap(async buffer => { mergeMap(async buffer => {
@@ -190,7 +206,7 @@ export class MailAdapterService {
} else if (item.type === 'text/plain') { } else if (item.type === 'text/plain') {
return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe( return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe(
map(text => { map(text => {
result.html = {value: new TextDecoder().decode(text), signature}; result.text = {value: new TextDecoder().decode(text), signature};
return result; return result;
}), }),
); );
@@ -204,34 +220,113 @@ export class MailAdapterService {
} else if (item.part === undefined) { } else if (item.part === undefined) {
return of(result); return of(result);
} else { } 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 of(result);
} }
}; };
return traverse(email.bodyStructure, {attachments: []}).pipe( const emailWithoutBody: Omit<EmailWithoutBody, 'partial'> = {
map( id: email.seq,
partial => mailbox,
({ flags: new Set<string>(email.flags),
...partial, subject: value(email.envelope.subject),
id: email.seq, from: email.envelope.from[0]
mailbox, ? value({
flags: new Set<string>(email.flags), name: email.envelope.from[0].name,
subject: value(email.envelope.subject), address: email.envelope.from[0].address,
from: value({ })
name: email.envelope.from[0]?.name || undefined, : value({
address: email.envelope.from[0]?.address || undefined, name: email.envelope.sender[0].name,
}), address: email.envelope.sender[0].address,
date: value(email.envelope.date), }),
}) satisfies Email, 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> { getEmail(
return this.getRawEmail(credentials, mailbox, id).pipe( 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)), 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 {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
import {AsyncPipe, TitleCasePipe} from '@angular/common'; import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {IonicModule} from '@ionic/angular'; import {IonRouterOutlet, IonicModule} from '@ionic/angular';
import {DataModule} from '../data/data.module'; import {DataModule} from '../data/data.module';
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module'; import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
import {UtilModule} from 'src/app/util/util.module'; import {UtilModule} from 'src/app/util/util.module';
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns'; import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
import {ActivatedRoute, RouterModule} from '@angular/router'; 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 {DomSanitizer} from '@angular/platform-browser';
import {materialFade} from 'src/app/animation/material-motion'; import {materialFade} from 'src/app/animation/material-motion';
import {TranslateModule} from '@ngx-translate/core'; import {TranslateModule} from '@ngx-translate/core';
import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive'; import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
import {MailStorageProvider} from './mail-storage.provider'; 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({ @Component({
selector: 'stapps-mail-detail', selector: 'stapps-mail-detail',
@@ -32,6 +36,7 @@ import {MailStorageProvider} from './mail-storage.provider';
ShadowHtmlDirective, ShadowHtmlDirective,
TranslateModule, TranslateModule,
TitleCasePipe, TitleCasePipe,
DataSizePipe,
], ],
}) })
export class MailDetailComponent { export class MailDetailComponent {
@@ -39,8 +44,12 @@ export class MailDetailComponent {
readonly mailStorage = inject(MailStorageProvider); readonly mailStorage = inject(MailStorageProvider);
readonly mailService = inject(MailService);
readonly sanitizer = inject(DomSanitizer); readonly sanitizer = inject(DomSanitizer);
readonly router = inject(IonRouterOutlet);
parameters = this.activatedRoute.paramMap.pipe( parameters = this.activatedRoute.paramMap.pipe(
map(parameters => ({ map(parameters => ({
mailbox: parameters.get('mailbox')!, mailbox: parameters.get('mailbox')!,
@@ -52,7 +61,42 @@ export class MailDetailComponent {
collapse = signal(false); collapse = signal(false);
todo() { constructor() {
alert('TODO'); 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-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button (click)="todo()"> <ion-button (click)="delete()">
<ion-icon slot="icon-only" name="delete"></ion-icon> <ion-icon slot="icon-only" name="delete"></ion-icon>
</ion-button> </ion-button>
<ion-button (click)="todo()"> <ion-button (click)="markUnread()">
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon> <ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
</ion-button> </ion-button>
</ion-buttons> </ion-buttons>
@@ -28,16 +28,21 @@
@if (mail | async; as mail) { @if (mail | async; as mail) {
<h1 @materialFade>{{ mail.subject?.value }}</h1> <h1 @materialFade>{{ mail.subject?.value }}</h1>
<aside @materialFade> <aside @materialFade>
<strong class="from"> <div class="from">
{{ mail.from.value.name || mail.from.value.address }} @if (mail.from.value.name) {
<strong>
{{ mail.from.value.name }}
</strong>
}
{{ mail.from.value.address }}
@if (mail.from.signature?.valid === true) { @if (mail.from.signature?.valid === true) {
<ion-icon name="verified" [fill]="true" @materialFade></ion-icon> <ion-icon name="verified" [fill]="true" @materialFade></ion-icon>
} @else if (mail.from.signature?.valid === false) { } @else if (mail.from.signature?.valid === false) {
<ion-icon name="gpp_bad" color="danger" [fill]="true" @materialFade></ion-icon> <ion-icon name="gpp_bad" color="danger" [fill]="true" @materialFade></ion-icon>
} }
</strong> </div>
<div class="to"> <div class="to">
to <strong>to</strong>
@for (to of mail.to; track to) { @for (to of mail.to; track to) {
<span>{{ to.value.name || to.value.address }}</span> <span>{{ to.value.name || to.value.address }}</span>
} }
@@ -50,6 +55,10 @@
} }
</div> </div>
} }
@if (mail.date) {
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
}
</aside> </aside>
@if (mail.html) { @if (mail.html) {
<main @materialFade> <main @materialFade>
@@ -60,34 +69,13 @@
<pre>{{ mail.text.value }}</pre> <pre>{{ mail.text.value }}</pre>
</main> </main>
} }
<ion-list> <div class="attachments">
@for (attachment of mail.attachments; track attachment) { @for (attachment of mail.attachments; track attachment) {
<ion-card> <ion-button class="attachment" fill="outline" (click)="downloadAttachment(attachment.value)">
<ion-card-header> <ion-icon slot="start" name="download"></ion-icon>
<ion-card-title>{{ attachment.value.filename }}</ion-card-title> <ion-label>{{ attachment.value.filename }} ({{ attachment.value.size | dataSize }})</ion-label>
<ion-card-subtitle>{{ attachment.value.size }}</ion-card-subtitle> </ion-button>
</ion-card-header>
<ion-card-content>
<ion-button fill="clear" (click)="todo()">
<ion-icon slot="icon-only" name="download"></ion-icon>
</ion-button>
</ion-card-content>
</ion-card>
} }
</ion-list> </div>
<footer>
<div>
<table>
@if (mail.date) {
<tr>
<th>{{ 'mail.DATE' | translate | titlecase }}</th>
<td>
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
</td>
</tr>
}
</table>
</div>
</footer>
} }
</ion-content> </ion-content>

View File

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

View File

@@ -21,7 +21,7 @@ import {
firstValueFrom, firstValueFrom,
Subject, Subject,
} from 'rxjs'; } from 'rxjs';
import {Email, EmailMeta, MailboxTreeRoot} from './schema'; import {Email, EmailMeta, EmailWithoutBody, MailboxTreeRoot} from './schema';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
import {MailAdapterService} from './mail-adapter.service'; import {MailAdapterService} from './mail-adapter.service';
import {StorageProvider} from '../storage/storage.provider'; import {StorageProvider} from '../storage/storage.provider';
@@ -147,7 +147,9 @@ export class MailStorageProvider {
const request = index.getAll(IDBKeyRange.only(mailbox)); const request = index.getAll(IDBKeyRange.only(mailbox));
return merge( 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( fromEvent(request, 'error').pipe(
map(event => { map(event => {
throw (event.target as IDBRequest).error; throw (event.target as IDBRequest).error;
@@ -156,7 +158,7 @@ export class MailStorageProvider {
).pipe(take(1)); ).pipe(take(1));
}); });
}), }),
map<Array<Email | EmailMeta>, EmailMeta[]>(emails => map<Array<Email | EmailWithoutBody | EmailMeta>, EmailMeta[]>(emails =>
emails emails
.map(email => ({id: email.id, mailbox: email.mailbox, incomplete: true}) satisfies EmailMeta) .map(email => ({id: email.id, mailbox: email.mailbox, incomplete: true}) satisfies EmailMeta)
.sort((a, b) => Number(b.id) - Number(a.id)), .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( return this.emailChanged.pipe(
filter(it => it.has(JSON.stringify([id, mailbox]))), filter(it => it.has(JSON.stringify([id, mailbox]))),
startWith(undefined), startWith(undefined),
@@ -188,16 +193,14 @@ export class MailStorageProvider {
}); });
}), }),
mergeMap(email => mergeMap(email =>
'incomplete' in email 'incomplete' in email || (!partial && 'partial' in email)
? this.credentials.pipe( ? this.credentials.pipe(
filter(it => it !== undefined), filter(it => it !== undefined),
take(1), take(1),
mergeMap(credentials => mergeMap(credentials =>
this.mailAdapter.getEmail(credentials!, mailbox, id).pipe( this.mailAdapter.getEmail(credentials!, mailbox, id, partial).pipe(
mergeMap(async email => { mergeMap(async email => {
console.log('fetiching');
await this.setEmail(email, true); await this.setEmail(email, true);
console.log('done');
return email; 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 database = await firstValueFrom(this.database);
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite'); const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME); 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 {MailService} from './mail.service';
import {map, take} from 'rxjs'; import {map, take} from 'rxjs';
/**
*
*/
function mailLoginGuard() { function mailLoginGuard() {
const router = inject(Router); const router = inject(Router);
return inject(MailService).isLoggedIn.pipe( return inject(MailService).isLoggedIn.pipe(

View File

@@ -1,7 +1,7 @@
import {Pipe, PipeTransform, inject} from '@angular/core'; import {Pipe, PipeTransform, inject} from '@angular/core';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {MailStorageProvider} from './mail-storage.provider'; import {MailStorageProvider} from './mail-storage.provider';
import {Email} from './schema'; import {Email, EmailWithoutBody} from './schema';
@Pipe({ @Pipe({
name: 'mail', name: 'mail',
@@ -12,6 +12,19 @@ export class MailPipe implements PipeTransform {
mailStorage = inject(MailStorageProvider); mailStorage = inject(MailStorageProvider);
transform(value: {mailbox: string; id: string}): Observable<Email> { 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, of,
catchError, catchError,
BehaviorSubject, BehaviorSubject,
take,
} from 'rxjs'; } from 'rxjs';
import {MailStorageProvider} from './mail-storage.provider'; import {MailStorageProvider} from './mail-storage.provider';
import {MailAdapterService} from './mail-adapter.service'; import {MailAdapterService} from './mail-adapter.service';
@@ -48,10 +49,15 @@ export class MailService {
mergeMap(([credentials, mailboxes]) => mergeMap(([credentials, mailboxes]) =>
from(mailboxes.folders).pipe( from(mailboxes.folders).pipe(
mergeMap(async mailbox => { mergeMap(async mailbox => {
return this.mailAdapter.listRawEmails(credentials!, mailbox.path).pipe( return this.mailAdapter.countEmails(credentials!, mailbox.path).pipe(
map<string[], EmailMeta[]>(emails => map<number, EmailMeta[]>(count =>
emails.map(it => ({id: it, mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta), Array.from(
{length: count},
(_, i) =>
({id: (i + 1).toString(), mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta,
),
), ),
tap(emails => console.log(emails)),
catchError(error => { catchError(error => {
console.error(error); console.error(error);
return of(); return of();
@@ -81,4 +87,12 @@ export class MailService {
this.mailStorage.setCredentials(undefined); this.mailStorage.setCredentials(undefined);
this.mailStorage.setMailboxes(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 {TranslateModule} from '@ngx-translate/core';
import {AsyncPipe, TitleCasePipe} from '@angular/common'; import {AsyncPipe, TitleCasePipe} from '@angular/common';
import {LazyComponent} from 'src/app/util/lazy.component'; 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 {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe'; import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
@@ -24,7 +24,7 @@ import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
TranslateModule, TranslateModule,
AsyncPipe, AsyncPipe,
LazyComponent, LazyComponent,
MailPipe, PartialMailPipe,
IsTodayPipeModule, IsTodayPipeModule,
FormatPurePipeModule, FormatPurePipeModule,
RouterModule, RouterModule,

View File

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

View File

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

View File

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

15
pnpm-lock.yaml generated
View File

@@ -260,6 +260,9 @@ importers:
commander: commander:
specifier: 10.0.0 specifier: 10.0.0
version: 10.0.0 version: 10.0.0
cors:
specifier: 2.8.5
version: 2.8.5
dotenv: dotenv:
specifier: 16.4.5 specifier: 16.4.5
version: 16.4.5 version: 16.4.5
@@ -291,6 +294,9 @@ importers:
'@openstapps/tsconfig': '@openstapps/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../configuration/tsconfig version: link:../../configuration/tsconfig
'@types/cors':
specifier: 2.8.13
version: 2.8.13
'@types/express': '@types/express':
specifier: 4.17.17 specifier: 4.17.17
version: 4.17.17 version: 4.17.17
@@ -8066,13 +8072,6 @@ packages:
resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
dependencies: dependencies:
'@types/node': 18.15.3 '@types/node': 18.15.3
dev: false
/@types/cors@2.8.17:
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
dependencies:
'@types/node': 18.15.3
dev: true
/@types/d3-scale-chromatic@3.0.3: /@types/d3-scale-chromatic@3.0.3:
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
@@ -12111,7 +12110,7 @@ packages:
engines: {node: '>=10.2.0'} engines: {node: '>=10.2.0'}
dependencies: dependencies:
'@types/cookie': 0.4.1 '@types/cookie': 0.4.1
'@types/cors': 2.8.17 '@types/cors': 2.8.13
'@types/node': 18.15.3 '@types/node': 18.15.3
accepts: 1.3.8 accepts: 1.3.8
base64id: 2.0.0 base64id: 2.0.0