mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-22 17:42:57 +00:00
Compare commits
2 Commits
161-simple
...
58a86e2389
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a86e2389 | ||
| ef6c25ee4b |
5
.changeset/pink-crabs-bathe.md
Normal file
5
.changeset/pink-crabs-bathe.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@openstapps/app": minor
|
||||
---
|
||||
|
||||
Use user facing changelogs in the about pages as the primary source, with the technical changes accessible through a sub menu.
|
||||
@@ -29,7 +29,6 @@
|
||||
"@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",
|
||||
@@ -42,7 +41,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,74 +1,36 @@
|
||||
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<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) => {
|
||||
try {
|
||||
const authorization = request.headers['authorization'];
|
||||
if (!authorization) {
|
||||
response.status(401).send();
|
||||
return;
|
||||
}
|
||||
const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64')
|
||||
.toString('utf8')
|
||||
.split(':');
|
||||
|
||||
const clientUid = createHash('sha256').update(authorization).digest('hex');
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.server.uni-frankfurt.de',
|
||||
port: 993,
|
||||
secure: true,
|
||||
emitLogs: false,
|
||||
auth: {user, pass},
|
||||
});
|
||||
response.locals.client = client;
|
||||
|
||||
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;
|
||||
await client.connect();
|
||||
response.on('finish', async () => {
|
||||
await client.logout();
|
||||
client.close();
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
await Logger.error(error);
|
||||
response.status(500).send();
|
||||
} catch {
|
||||
response.status(401).send();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,67 +41,52 @@ 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});
|
||||
response.json({messages: preData.messages});
|
||||
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);
|
||||
} catch (error) {
|
||||
await Logger.error(error);
|
||||
console.error(error);
|
||||
response.status(404).send();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/:mailbox/:id', async (request, response) => {
|
||||
try {
|
||||
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: true});
|
||||
await response.locals.client.mailboxOpen(request.params.mailbox);
|
||||
const message = await response.locals.client.fetchOne(request.params.id, {
|
||||
envelope: true,
|
||||
labels: true,
|
||||
flags: true,
|
||||
bodyStructure: request.query.partial ? false : true,
|
||||
bodyStructure: true,
|
||||
});
|
||||
response.json({
|
||||
bodyStructure: request.query.partial ? undefined : message.bodyStructure,
|
||||
bodyStructure: message.bodyStructure,
|
||||
labels: [...(message.labels ?? [])],
|
||||
flags: [...(message.flags ?? [])],
|
||||
envelope: message.envelope,
|
||||
seq: message.seq,
|
||||
});
|
||||
} catch (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);
|
||||
console.error(error);
|
||||
response.status(404).send();
|
||||
}
|
||||
});
|
||||
@@ -166,11 +113,11 @@ app.get('/:mailbox/:id/:part', async (request, response) => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await Logger.error(error);
|
||||
console.error(error);
|
||||
response.status(404).send();
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
Logger.info(`Server listening on port ${port}`);
|
||||
console.log(`Server listening on port ${port}`);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# @openstapps/app
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8b581ef9: Use user facing changelogs in the about pages as the primary source, with the technical changes accessible through a sub menu.
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@openstapps/app",
|
||||
"description": "The generic app tailored to fulfill needs of German universities, written using Ionic Framework.",
|
||||
"version": "3.3.4",
|
||||
"version": "3.3.3",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"author": "Karl-Philipp Wulfert <krlwlfrt@gmail.com>",
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {Observable, map, catchError, tap, mergeMap, forkJoin, of} from 'rxjs';
|
||||
import {
|
||||
Email,
|
||||
EmailWithoutBody,
|
||||
MailboxTreeRoot,
|
||||
RawEmail,
|
||||
RawEmailBodyStructure,
|
||||
Signature,
|
||||
SignedValue,
|
||||
} from './schema';
|
||||
import {Email, MailboxTreeRoot, RawEmail, RawEmailBodyStructure, Signature, SignedValue} from './schema';
|
||||
import {ContentInfo, SignedData} from 'pkijs';
|
||||
import PostalMime from 'postal-mime';
|
||||
import {z} from 'zod';
|
||||
@@ -30,7 +22,7 @@ export class MailAdapterService {
|
||||
request<T>(options: {
|
||||
method?: string;
|
||||
path?: string[];
|
||||
options?: Record<string, string | string[]>;
|
||||
options?: Record<string, string>;
|
||||
responseType?: 'json' | 'arraybuffer';
|
||||
credentials?: string;
|
||||
}): Observable<T> {
|
||||
@@ -38,13 +30,7 @@ export class MailAdapterService {
|
||||
options.method ?? 'GET',
|
||||
`http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
|
||||
options.options
|
||||
? `?${Object.entries(options.options)
|
||||
.flatMap(([key, values]) =>
|
||||
(Array.isArray(values) ? values : [values]).map(
|
||||
value => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
||||
),
|
||||
)
|
||||
.join('&')}`
|
||||
? `?${Object.entries(options.options).map(item => item.map(encodeURIComponent).join('='))}`
|
||||
: ''
|
||||
}`,
|
||||
{
|
||||
@@ -76,27 +62,22 @@ export class MailAdapterService {
|
||||
return this.request<string[]>({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it)));
|
||||
}
|
||||
|
||||
countEmails(credentials: string, mailbox: string, since?: string): Observable<number> {
|
||||
listRawEmails(credentials: string, mailbox: string, since?: string): Observable<string[]> {
|
||||
return this.request<unknown>({
|
||||
credentials,
|
||||
path: [mailbox],
|
||||
options: since === undefined ? undefined : {since},
|
||||
}).pipe(
|
||||
mergeMap(it => z.object({messages: z.number()}).parseAsync(it)),
|
||||
map(it => it.messages),
|
||||
mergeMap(it => z.array(z.string()).parseAsync(it)),
|
||||
tap(console.log),
|
||||
);
|
||||
}
|
||||
|
||||
private getRawEmail(
|
||||
credentials: string,
|
||||
mailbox: string,
|
||||
id: string,
|
||||
partial: boolean,
|
||||
): Observable<RawEmail> {
|
||||
private getRawEmail(credentials: string, mailbox: string, id: string): Observable<RawEmail> {
|
||||
return this.request<unknown>({
|
||||
credentials,
|
||||
path: [mailbox, id],
|
||||
options: partial ? {raw: 'true', partial: 'true'} : {raw: 'true'},
|
||||
options: {raw: 'true'},
|
||||
}).pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
||||
}
|
||||
|
||||
@@ -104,7 +85,12 @@ export class MailAdapterService {
|
||||
return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'});
|
||||
}
|
||||
|
||||
getRawPart(credentials: string, mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
|
||||
private getRawPart(
|
||||
credentials: string,
|
||||
mailbox: string,
|
||||
id: string,
|
||||
part: string,
|
||||
): Observable<ArrayBuffer> {
|
||||
return this.request({
|
||||
path: [mailbox, id, part],
|
||||
options: {raw: 'true'},
|
||||
@@ -113,14 +99,12 @@ export class MailAdapterService {
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRawEmail(
|
||||
credentials: string,
|
||||
mailbox: string,
|
||||
email: RawEmail,
|
||||
): Observable<Email | EmailWithoutBody> {
|
||||
private resolveRawEmail(credentials: string, mailbox: string, email: RawEmail): Observable<Email> {
|
||||
console.log(email);
|
||||
|
||||
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 => {
|
||||
@@ -206,7 +190,7 @@ export class MailAdapterService {
|
||||
} else if (item.type === 'text/plain') {
|
||||
return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe(
|
||||
map(text => {
|
||||
result.text = {value: new TextDecoder().decode(text), signature};
|
||||
result.html = {value: new TextDecoder().decode(text), signature};
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
@@ -220,113 +204,34 @@ 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: item.parameters?.['name'] ?? item.part,
|
||||
},
|
||||
});
|
||||
result.attachments.push({value: {part: item.part, size: item.size ?? Number.NaN, filename: ''}});
|
||||
return of(result);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
partial: boolean,
|
||||
): Observable<Email | EmailWithoutBody> {
|
||||
return this.getRawEmail(credentials, mailbox, id, partial).pipe(
|
||||
mergeMap(it => this.resolveRawEmail(credentials, mailbox, it)),
|
||||
tap(console.log),
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
});
|
||||
getEmail(credentials: string, mailbox: string, id: string): Observable<Email> {
|
||||
return this.getRawEmail(credentials, mailbox, id).pipe(
|
||||
mergeMap(it => this.resolveRawEmail(credentials, mailbox, it)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
|
||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||
import {IonRouterOutlet, IonicModule} from '@ionic/angular';
|
||||
import {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 {firstValueFrom, map, mergeMap, take} from 'rxjs';
|
||||
import {map, mergeMap} from 'rxjs';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {materialFade} from 'src/app/animation/material-motion';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
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',
|
||||
@@ -36,7 +32,6 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
ShadowHtmlDirective,
|
||||
TranslateModule,
|
||||
TitleCasePipe,
|
||||
DataSizePipe,
|
||||
],
|
||||
})
|
||||
export class MailDetailComponent {
|
||||
@@ -44,12 +39,8 @@ 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')!,
|
||||
@@ -61,42 +52,7 @@ export class MailDetailComponent {
|
||||
|
||||
collapse = signal(false);
|
||||
|
||||
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();
|
||||
todo() {
|
||||
alert('TODO');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
}
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="delete()">
|
||||
<ion-button (click)="todo()">
|
||||
<ion-icon slot="icon-only" name="delete"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="markUnread()">
|
||||
<ion-button (click)="todo()">
|
||||
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
@@ -28,21 +28,16 @@
|
||||
@if (mail | async; as mail) {
|
||||
<h1 @materialFade>{{ mail.subject?.value }}</h1>
|
||||
<aside @materialFade>
|
||||
<div class="from">
|
||||
@if (mail.from.value.name) {
|
||||
<strong>
|
||||
{{ mail.from.value.name }}
|
||||
</strong>
|
||||
}
|
||||
{{ mail.from.value.address }}
|
||||
<strong class="from">
|
||||
{{ mail.from.value.name || 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>
|
||||
}
|
||||
</div>
|
||||
</strong>
|
||||
<div class="to">
|
||||
<strong>to</strong>
|
||||
to
|
||||
@for (to of mail.to; track to) {
|
||||
<span>{{ to.value.name || to.value.address }}</span>
|
||||
}
|
||||
@@ -55,10 +50,6 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (mail.date) {
|
||||
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
|
||||
}
|
||||
</aside>
|
||||
@if (mail.html) {
|
||||
<main @materialFade>
|
||||
@@ -69,13 +60,34 @@
|
||||
<pre>{{ mail.text.value }}</pre>
|
||||
</main>
|
||||
}
|
||||
<div class="attachments">
|
||||
<ion-list>
|
||||
@for (attachment of mail.attachments; track attachment) {
|
||||
<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-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>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
</ion-content>
|
||||
|
||||
@@ -49,26 +49,6 @@ 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;
|
||||
}
|
||||
@@ -100,17 +80,16 @@ footer {
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
.attachment {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
margin-block-end: var(--spacing-xl);
|
||||
margin-inline: var(--spacing-md);
|
||||
}
|
||||
margin: var(--spacing-md) 0;
|
||||
padding: var(--spacing-md);
|
||||
|
||||
.attachment[fill='outline']::part(native) {
|
||||
border: 1px solid currentcolor;
|
||||
border: 1px solid var(--ion-border-color);
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
ion-content::part(background) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
firstValueFrom,
|
||||
Subject,
|
||||
} from 'rxjs';
|
||||
import {Email, EmailMeta, EmailWithoutBody, MailboxTreeRoot} from './schema';
|
||||
import {Email, EmailMeta, MailboxTreeRoot} from './schema';
|
||||
import equal from 'fast-deep-equal';
|
||||
import {MailAdapterService} from './mail-adapter.service';
|
||||
import {StorageProvider} from '../storage/storage.provider';
|
||||
@@ -147,9 +147,7 @@ export class MailStorageProvider {
|
||||
const request = index.getAll(IDBKeyRange.only(mailbox));
|
||||
|
||||
return merge(
|
||||
fromEvent(request, 'success').pipe(
|
||||
map(() => request.result as Array<EmailMeta | EmailWithoutBody | Email>),
|
||||
),
|
||||
fromEvent(request, 'success').pipe(map(() => request.result as Array<EmailMeta | Email>)),
|
||||
fromEvent(request, 'error').pipe(
|
||||
map(event => {
|
||||
throw (event.target as IDBRequest).error;
|
||||
@@ -158,7 +156,7 @@ export class MailStorageProvider {
|
||||
).pipe(take(1));
|
||||
});
|
||||
}),
|
||||
map<Array<Email | EmailWithoutBody | EmailMeta>, EmailMeta[]>(emails =>
|
||||
map<Array<Email | 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)),
|
||||
@@ -167,10 +165,7 @@ export class MailStorageProvider {
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
getEmail(mailbox: string, id: string): Observable<Email> {
|
||||
return this.emailChanged.pipe(
|
||||
filter(it => it.has(JSON.stringify([id, mailbox]))),
|
||||
startWith(undefined),
|
||||
@@ -193,14 +188,16 @@ export class MailStorageProvider {
|
||||
});
|
||||
}),
|
||||
mergeMap(email =>
|
||||
'incomplete' in email || (!partial && 'partial' in email)
|
||||
'incomplete' in email
|
||||
? this.credentials.pipe(
|
||||
filter(it => it !== undefined),
|
||||
take(1),
|
||||
mergeMap(credentials =>
|
||||
this.mailAdapter.getEmail(credentials!, mailbox, id, partial).pipe(
|
||||
this.mailAdapter.getEmail(credentials!, mailbox, id).pipe(
|
||||
mergeMap(async email => {
|
||||
console.log('fetiching');
|
||||
await this.setEmail(email, true);
|
||||
console.log('done');
|
||||
return email;
|
||||
}),
|
||||
),
|
||||
@@ -212,10 +209,7 @@ export class MailStorageProvider {
|
||||
);
|
||||
}
|
||||
|
||||
async setEmail(
|
||||
email: Email | EmailMeta | EmailWithoutBody | Array<Email | EmailMeta | EmailWithoutBody>,
|
||||
quiet = false,
|
||||
): Promise<void> {
|
||||
async setEmail(email: Email | EmailMeta | Array<Email | EmailMeta>, 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);
|
||||
@@ -246,64 +240,4 @@ 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,9 +17,6 @@ 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, EmailWithoutBody} from './schema';
|
||||
import {Email} from './schema';
|
||||
|
||||
@Pipe({
|
||||
name: 'mail',
|
||||
@@ -12,19 +12,6 @@ export class MailPipe implements PipeTransform {
|
||||
mailStorage = inject(MailStorageProvider);
|
||||
|
||||
transform(value: {mailbox: string; id: string}): Observable<Email> {
|
||||
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);
|
||||
return this.mailStorage.getEmail(value.mailbox, value.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
of,
|
||||
catchError,
|
||||
BehaviorSubject,
|
||||
take,
|
||||
} from 'rxjs';
|
||||
import {MailStorageProvider} from './mail-storage.provider';
|
||||
import {MailAdapterService} from './mail-adapter.service';
|
||||
@@ -49,15 +48,10 @@ export class MailService {
|
||||
mergeMap(([credentials, mailboxes]) =>
|
||||
from(mailboxes.folders).pipe(
|
||||
mergeMap(async mailbox => {
|
||||
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,
|
||||
),
|
||||
return this.mailAdapter.listRawEmails(credentials!, mailbox.path).pipe(
|
||||
map<string[], EmailMeta[]>(emails =>
|
||||
emails.map(it => ({id: it, mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta),
|
||||
),
|
||||
tap(emails => console.log(emails)),
|
||||
catchError(error => {
|
||||
console.error(error);
|
||||
return of();
|
||||
@@ -87,12 +81,4 @@ 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 {PartialMailPipe} from './mail.pipe';
|
||||
import {MailPipe} 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,
|
||||
PartialMailPipe,
|
||||
MailPipe,
|
||||
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 | partialMail | lazyLoad: item | async; as mail) {
|
||||
@if (mail | mail | 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%;
|
||||
}
|
||||
|
||||
h2.unread {
|
||||
ion-item.unread h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export const RawEmailBodyStructure: z.ZodType<RawEmailBodyStructure> = RawEmailB
|
||||
});
|
||||
|
||||
export const RawEmail = z.object({
|
||||
bodyStructure: z.optional(RawEmailBodyStructure),
|
||||
bodyStructure: RawEmailBodyStructure,
|
||||
labels: z.array(z.string()).transform(it => new Set(it)),
|
||||
flags: z.array(z.string()).transform(it => new Set(it)),
|
||||
envelope: RawEmailEnvelope,
|
||||
@@ -125,6 +125,4 @@ 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};
|
||||
|
||||
@@ -19,7 +19,6 @@ import german from '../../assets/i18n/de.json';
|
||||
|
||||
const exceptions = new Set(
|
||||
[
|
||||
'ID',
|
||||
'login',
|
||||
'ok',
|
||||
'protein',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {SCRange, isInRange, SCISO8601DateRange} from '@openstapps/core';
|
||||
import {NormalizedInterval, differenceInMilliseconds, interval} from 'date-fns';
|
||||
import {NormalizedInterval, differenceInMilliseconds, interval, isEqual} from 'date-fns';
|
||||
import {EMPTY, Observable, SchedulerLike, asyncScheduler, concat, defer, map, of, timer} from 'rxjs';
|
||||
|
||||
@Pipe({
|
||||
@@ -16,8 +16,6 @@ export class InRangePipe implements PipeTransform {
|
||||
|
||||
export const MIN_DATE = new Date(0);
|
||||
export const MAX_DATE = new Date(1e15);
|
||||
// Maximum safe delay for JavaScript timers (~24.8 days)
|
||||
export const MAX_DELAY = 2 ** 31 - 1;
|
||||
|
||||
@Pipe({
|
||||
name: 'rangeToDateInterval',
|
||||
@@ -45,7 +43,7 @@ export function isWithinIntervalObservable(
|
||||
return concat(
|
||||
of(activate <= 0 && deactivate > 0),
|
||||
activate <= 0 ? EMPTY : timer(value.start, scheduler).pipe(map(() => true)),
|
||||
differenceInMilliseconds(value.end, now) >= MAX_DELAY || deactivate <= 0
|
||||
isEqual(value.end, MAX_DATE) || deactivate <= 0
|
||||
? EMPTY
|
||||
: timer(value.end, scheduler).pipe(map(() => false)),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {TestScheduler} from 'rxjs/testing';
|
||||
import {MAX_DATE, MIN_DATE, isWithinIntervalObservable} from './in-range.pipe';
|
||||
import {interval} from 'date-fns';
|
||||
import {MAX_DELAY} from './in-range.pipe';
|
||||
|
||||
/**
|
||||
* Test macro
|
||||
@@ -40,7 +39,4 @@ describe('isWithinIntervalObservable', () => {
|
||||
test([500, 1000], '499ms ^', '499ms f t 499ms (f|)');
|
||||
|
||||
test([500, 1000], '^ 750ms !', 'f 499ms t');
|
||||
|
||||
// Long interval test case: emit `true` and then complete (EMPTY) because `end` is beyond the delay limit
|
||||
test([500, 500 + MAX_DELAY + 2000], '1s ^', '1s (t|)');
|
||||
});
|
||||
|
||||
Binary file not shown.
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -260,9 +260,6 @@ 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
|
||||
@@ -294,9 +291,6 @@ 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
|
||||
@@ -8072,6 +8066,13 @@ 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==}
|
||||
@@ -12110,7 +12111,7 @@ packages:
|
||||
engines: {node: '>=10.2.0'}
|
||||
dependencies:
|
||||
'@types/cookie': 0.4.1
|
||||
'@types/cors': 2.8.13
|
||||
'@types/cors': 2.8.17
|
||||
'@types/node': 18.15.3
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user