diff --git a/backend/mail-plugin/package.json b/backend/mail-plugin/package.json index 641aa020..4593d6e5 100644 --- a/backend/mail-plugin/package.json +++ b/backend/mail-plugin/package.json @@ -29,6 +29,7 @@ "@openstapps/core-tools": "workspace:*", "@openstapps/logger": "workspace:*", "commander": "10.0.0", + "cors": "2.8.5", "dotenv": "16.4.5", "express": "4.18.2", "imapflow": "1.0.162", @@ -41,6 +42,7 @@ "@openstapps/eslint-config": "workspace:*", "@openstapps/prettier-config": "workspace:*", "@openstapps/tsconfig": "workspace:*", + "@types/cors": "2.8.13", "@types/express": "4.17.17", "@types/imapflow": "1.0.18", "@types/mailparser": "3.4.4", diff --git a/backend/mail-plugin/src/cli.ts b/backend/mail-plugin/src/cli.ts index 16ddef18..b9ff6950 100644 --- a/backend/mail-plugin/src/cli.ts +++ b/backend/mail-plugin/src/cli.ts @@ -1,36 +1,74 @@ import {config} from 'dotenv'; import {ImapFlow} from 'imapflow'; +import {Logger} from '@openstapps/logger'; +import {createHash} from 'node:crypto'; import express from 'express'; +import cors from 'cors'; config({path: '.env.local'}); const app = express(); const port = process.env.PORT || 4000; +const maxClientAge = 10_000; // 10 seconds + +const clients = new Map}>(); + +/** + * + */ +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) => { try { - const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64') - .toString('utf8') - .split(':'); + const authorization = request.headers['authorization']; + if (!authorization) { + response.status(401).send(); + return; + } - const client = new ImapFlow({ - host: 'imap.server.uni-frankfurt.de', - port: 993, - secure: true, - emitLogs: false, - auth: {user, pass}, - }); - response.locals.client = client; + const clientUid = createHash('sha256').update(authorization).digest('hex'); - await client.connect(); - response.on('finish', async () => { - await client.logout(); - client.close(); - }); + let client = clients.get(clientUid); + if (client === undefined) { + const [user, pass] = Buffer.from(authorization.replace(/^Basic /, ''), 'base64') + .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(); - } catch { - response.status(401).send(); + } catch (error) { + await Logger.error(error); + response.status(500).send(); } }); @@ -41,52 +79,67 @@ app.get('/', async (_request, response) => { app.get('/:mailbox', async (request, response) => { 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}); - if (preData.messages === 0) { - 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); + response.json({messages: preData.messages}); } catch (error) { - console.error(error); + await Logger.error(error); response.status(404).send(); } }); app.get('/:mailbox/:id', async (request, response) => { 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, { envelope: true, labels: true, flags: true, - bodyStructure: true, + bodyStructure: request.query.partial ? false : true, }); response.json({ - bodyStructure: message.bodyStructure, + bodyStructure: request.query.partial ? undefined : message.bodyStructure, labels: [...(message.labels ?? [])], flags: [...(message.flags ?? [])], envelope: message.envelope, seq: message.seq, }); } catch (error) { - console.error(error); + await Logger.error(error); + response.status(404).send(); + } +}); + +/** + * + */ +function parseFlags(query: Record): 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(); } }); @@ -113,11 +166,11 @@ app.get('/:mailbox/:id/:part', async (request, response) => { }); } } catch (error) { - console.error(error); + await Logger.error(error); response.status(404).send(); } }); app.listen(port, () => { - console.log(`Server listening on port ${port}`); + Logger.info(`Server listening on port ${port}`); }); diff --git a/frontend/app/src/app/modules/mail/mail-adapter.service.ts b/frontend/app/src/app/modules/mail/mail-adapter.service.ts index 6b93b46f..42004155 100644 --- a/frontend/app/src/app/modules/mail/mail-adapter.service.ts +++ b/frontend/app/src/app/modules/mail/mail-adapter.service.ts @@ -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(options: { method?: string; path?: string[]; - options?: Record; + options?: Record; responseType?: 'json' | 'arraybuffer'; credentials?: string; }): Observable { return this.httpClient.request( 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({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it))); } - listRawEmails(credentials: string, mailbox: string, since?: string): Observable { + countEmails(credentials: string, mailbox: string, since?: string): Observable { return this.request({ 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 { + private getRawEmail( + credentials: string, + mailbox: string, + id: string, + partial: boolean, + ): Observable { return this.request({ 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({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'}); } - private getRawPart( - credentials: string, - mailbox: string, - id: string, - part: string, - ): Observable { + getRawPart(credentials: string, mailbox: string, id: string, part: string): Observable { 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 { - console.log(email); - + private resolveRawEmail( + credentials: string, + mailbox: string, + email: RawEmail, + ): Observable { 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(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 = { + id: email.seq, + mailbox, + flags: new Set(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 { - return this.getRawEmail(credentials, mailbox, id).pipe( + getEmail( + credentials: string, + mailbox: string, + id: string, + partial: boolean, + ): Observable { + 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 { + return Array.isArray(flags) && flags.length === 0 + ? of(true) + : this.request({ + credentials, + method: 'POST', + path: [mailbox, id], + options: { + flags: flags, + }, + }); + } + + removeFlags( + credentials: string, + mailbox: string, + id: string, + flags: string | string[], + ): Observable { + return Array.isArray(flags) && flags.length === 0 + ? of(true) + : this.request({ + credentials, + method: 'DELETE', + path: [mailbox, id], + options: { + flags: flags, + }, + }); + } + + deleteEmail(credentials: string, mailbox: string, id: string): Observable { + return this.request({ + credentials, + method: 'DELETE', + path: [mailbox, id], + }); + } } diff --git a/frontend/app/src/app/modules/mail/mail-detail.component.ts b/frontend/app/src/app/modules/mail/mail-detail.component.ts index 0f97446c..b26aeb07 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.component.ts +++ b/frontend/app/src/app/modules/mail/mail-detail.component.ts @@ -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(); } } diff --git a/frontend/app/src/app/modules/mail/mail-detail.html b/frontend/app/src/app/modules/mail/mail-detail.html index 079cc993..77bc0052 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.html +++ b/frontend/app/src/app/modules/mail/mail-detail.html @@ -14,10 +14,10 @@ } - + - + @@ -28,16 +28,21 @@ @if (mail | async; as mail) {

{{ mail.subject?.value }}

@if (mail.html) {
@@ -60,34 +69,13 @@
{{ mail.text.value }}
} - +
@for (attachment of mail.attachments; track attachment) { - - - {{ attachment.value.filename }} - {{ attachment.value.size }} - - - - - - - + + + {{ attachment.value.filename }} ({{ attachment.value.size | dataSize }}) + } - -
-
- - @if (mail.date) { - - - - - } -
{{ 'mail.DATE' | translate | titlecase }} - -
-
-
+
} diff --git a/frontend/app/src/app/modules/mail/mail-detail.scss b/frontend/app/src/app/modules/mail/mail-detail.scss index 0f19be5d..f923e2ad 100644 --- a/frontend/app/src/app/modules/mail/mail-detail.scss +++ b/frontend/app/src/app/modules/mail/mail-detail.scss @@ -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) { diff --git a/frontend/app/src/app/modules/mail/mail-storage.provider.ts b/frontend/app/src/app/modules/mail/mail-storage.provider.ts index 896901c9..721b3577 100644 --- a/frontend/app/src/app/modules/mail/mail-storage.provider.ts +++ b/frontend/app/src/app/modules/mail/mail-storage.provider.ts @@ -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)), + fromEvent(request, 'success').pipe( + map(() => request.result as Array), + ), fromEvent(request, 'error').pipe( map(event => { throw (event.target as IDBRequest).error; @@ -156,7 +158,7 @@ export class MailStorageProvider { ).pipe(take(1)); }); }), - map, EmailMeta[]>(emails => + map, 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 { + getEmail(mailbox: string, id: string): Observable; + getEmail(mailbox: string, id: string, partial: false): Observable; + getEmail(mailbox: string, id: string, partial: boolean): Observable; + getEmail(mailbox: string, id: string, partial = false): Observable { 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, quiet = false): Promise { + async setEmail( + email: Email | EmailMeta | EmailWithoutBody | Array, + quiet = false, + ): Promise { 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 { + 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 { + 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 { + 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; + }), + ), + ), + ); + } } diff --git a/frontend/app/src/app/modules/mail/mail.module.ts b/frontend/app/src/app/modules/mail/mail.module.ts index 9d9d991a..9ffa64ad 100644 --- a/frontend/app/src/app/modules/mail/mail.module.ts +++ b/frontend/app/src/app/modules/mail/mail.module.ts @@ -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( diff --git a/frontend/app/src/app/modules/mail/mail.pipe.ts b/frontend/app/src/app/modules/mail/mail.pipe.ts index 465bf766..aa12cf63 100644 --- a/frontend/app/src/app/modules/mail/mail.pipe.ts +++ b/frontend/app/src/app/modules/mail/mail.pipe.ts @@ -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 { - 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 { + return this.mailStorage.getEmail(value.mailbox, value.id, true); } } diff --git a/frontend/app/src/app/modules/mail/mail.service.ts b/frontend/app/src/app/modules/mail/mail.service.ts index 67c38f65..76b7ec9f 100644 --- a/frontend/app/src/app/modules/mail/mail.service.ts +++ b/frontend/app/src/app/modules/mail/mail.service.ts @@ -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(emails => - emails.map(it => ({id: it, mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta), + return this.mailAdapter.countEmails(credentials!, mailbox.path).pipe( + map(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 { + return this.mailStorage.credentials.pipe( + filter(it => it !== undefined), + take(1), + mergeMap(credentials => this.mailAdapter.getRawPart(credentials!, mailbox, id, part)), + ); + } } diff --git a/frontend/app/src/app/modules/mail/mailbox-page.component.ts b/frontend/app/src/app/modules/mail/mailbox-page.component.ts index 4c988066..33bf45ce 100644 --- a/frontend/app/src/app/modules/mail/mailbox-page.component.ts +++ b/frontend/app/src/app/modules/mail/mailbox-page.component.ts @@ -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, diff --git a/frontend/app/src/app/modules/mail/mailbox-page.html b/frontend/app/src/app/modules/mail/mailbox-page.html index 726fbbcb..06eb9722 100644 --- a/frontend/app/src/app/modules/mail/mailbox-page.html +++ b/frontend/app/src/app/modules/mail/mailbox-page.html @@ -15,7 +15,7 @@ @for (mail of mails; track mail.id) { - @if (mail | mail | lazyLoad: item | async; as mail) { + @if (mail | partialMail | lazyLoad: item | async; as mail) {
@if (mail.from; as from) {
diff --git a/frontend/app/src/app/modules/mail/mailbox-page.scss b/frontend/app/src/app/modules/mail/mailbox-page.scss index f9513ac7..c0c8d0f5 100644 --- a/frontend/app/src/app/modules/mail/mailbox-page.scss +++ b/frontend/app/src/app/modules/mail/mailbox-page.scss @@ -29,7 +29,7 @@ p > ion-skeleton-text { border-radius: 50%; } -ion-item.unread h2 { +h2.unread { font-weight: bold; } diff --git a/frontend/app/src/app/modules/mail/schema.ts b/frontend/app/src/app/modules/mail/schema.ts index 93d9e714..41bfbd77 100644 --- a/frontend/app/src/app/modules/mail/schema.ts +++ b/frontend/app/src/app/modules/mail/schema.ts @@ -42,7 +42,7 @@ export const RawEmailBodyStructure: z.ZodType = 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[]; } +export type EmailWithoutBody = Omit & {partial: true}; + export type EmailMeta = Pick & {incomplete: true}; diff --git a/frontend/app/src/assets/icons.min.woff2 b/frontend/app/src/assets/icons.min.woff2 index eb09213c..8444fc0a 100644 Binary files a/frontend/app/src/assets/icons.min.woff2 and b/frontend/app/src/assets/icons.min.woff2 differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87f103c6..c55c159d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: commander: specifier: 10.0.0 version: 10.0.0 + cors: + specifier: 2.8.5 + version: 2.8.5 dotenv: specifier: 16.4.5 version: 16.4.5 @@ -291,6 +294,9 @@ importers: '@openstapps/tsconfig': specifier: workspace:* version: link:../../configuration/tsconfig + '@types/cors': + specifier: 2.8.13 + version: 2.8.13 '@types/express': specifier: 4.17.17 version: 4.17.17 @@ -8066,13 +8072,6 @@ packages: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: '@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: resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} @@ -12111,7 +12110,7 @@ packages: engines: {node: '>=10.2.0'} dependencies: '@types/cookie': 0.4.1 - '@types/cors': 2.8.17 + '@types/cors': 2.8.13 '@types/node': 18.15.3 accepts: 1.3.8 base64id: 2.0.0