mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-04-28 00:59:23 +00:00
feat: more mail stuff
This commit is contained in:
@@ -1,4 +1,13 @@
|
||||
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||
import {ChangeDetectionStrategy, Component, WritableSignal, computed, inject, signal} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
||||
import {UtilModule} from 'src/app/util/util.module';
|
||||
import {MailService} from './mail.service';
|
||||
import {Observable, of, map, catchError, startWith, shareReplay, tap, take} from 'rxjs';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'stapps-mail-login',
|
||||
@@ -6,5 +15,50 @@ import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
styleUrl: 'mail-login.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [IonicModule, UtilModule, TranslateModule, TitleCasePipe, IonIconModule, FormsModule, AsyncPipe],
|
||||
})
|
||||
export class MailLoginComponent {}
|
||||
export class MailLoginComponent {
|
||||
showPassword = signal(false);
|
||||
|
||||
email = signal('');
|
||||
|
||||
password = signal('');
|
||||
|
||||
error: WritableSignal<Observable<string | undefined>> = signal(of(undefined));
|
||||
|
||||
loading = computed(() =>
|
||||
this.error().pipe(
|
||||
map(() => false),
|
||||
startWith(true),
|
||||
),
|
||||
);
|
||||
|
||||
mailService = inject(MailService);
|
||||
|
||||
router = inject(Router);
|
||||
|
||||
activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
if (form.checkValidity()) {
|
||||
this.error.set(
|
||||
this.mailService.login(this.email(), this.password()).pipe(
|
||||
tap(success => {
|
||||
if (success) {
|
||||
this.activatedRoute.data.pipe(take(1)).subscribe(data => {
|
||||
this.router.navigate(data.redirectTo);
|
||||
});
|
||||
}
|
||||
}),
|
||||
map(success => (success ? undefined : 'mail.login.error.INVALID_CREDENTIALS')),
|
||||
catchError(error => of(error.message)),
|
||||
shareReplay(1),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
form.reportValidity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary" mode="ios">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button></ion-back-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content parallax>
|
||||
<h1>{{ 'mail.login.TITLE' | translate | titlecase }}</h1>
|
||||
<form (ngSubmit)="submit($event)">
|
||||
<ion-input
|
||||
[attr.aria-label]="'mail.login.PLACEHOLDER_USERNAME' | translate | titlecase"
|
||||
[placeholder]="'mail.login.PLACEHOLDER_USERNAME' | translate | titlecase"
|
||||
[(ngModel)]="email"
|
||||
[required]="true"
|
||||
[disabled]="(error() | async) !== null ? false : true"
|
||||
autocomplete="username"
|
||||
enterkeyhint="next"
|
||||
color="primary"
|
||||
name="username"
|
||||
type="text"
|
||||
>
|
||||
<ion-icon slot="start" name="account_circle" aria-hiden="true"></ion-icon>
|
||||
</ion-input>
|
||||
<ion-input
|
||||
[attr.aria-label]="'mail.login.PLACEHOLDER_PASSWORD' | translate | titlecase"
|
||||
[placeholder]="'mail.login.PLACEHOLDER_PASSWORD' | translate | titlecase"
|
||||
[(ngModel)]="password"
|
||||
[required]="true"
|
||||
[disabled]="(error() | async) !== null ? false : true"
|
||||
autocomplete="current-password"
|
||||
enterkeyhint="go"
|
||||
name="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
>
|
||||
<ion-icon slot="start" name="password" aria-hidden="true"></ion-icon>
|
||||
<ion-button
|
||||
fill="clear"
|
||||
slot="end"
|
||||
color="medium"
|
||||
aria-label="Show/hide"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
>
|
||||
<ion-icon
|
||||
[size]="20"
|
||||
[fill]="!showPassword()"
|
||||
slot="icon-only"
|
||||
name="visibility"
|
||||
aria-hidden="true"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-input>
|
||||
@if (error() | async; as error) {
|
||||
<ion-note color="danger">{{ error | translate | titlecase }}</ion-note>
|
||||
}
|
||||
<ion-button
|
||||
fill="outline"
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="(error() | async) !== null ? false : true"
|
||||
>
|
||||
<ion-icon slot="start" name="login" aria-hidden="true"></ion-icon>
|
||||
{{ 'mail.login.LOGIN' | translate | titlecase }}
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
h1 {
|
||||
margin-inline: auto;
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
max-width: 30em;
|
||||
margin: var(--spacing-xxl) auto;
|
||||
padding: var(--spacing-xl);
|
||||
|
||||
background: var(--ion-item-background);
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
||||
import {UtilModule} from 'src/app/util/util.module';
|
||||
import {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
|
||||
import {ActivatedRoute, RouterModule} from '@angular/router';
|
||||
import {combineLatest, map, mergeMap} from 'rxjs';
|
||||
import {map, mergeMap} from 'rxjs';
|
||||
import {MailStorageProvider} from './mail-storage.provider';
|
||||
import {MailboxTreeItem} from './schema';
|
||||
import {SCIcon} from 'src/app/util/ion-icon/icon';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'stapps-mail-page',
|
||||
@@ -24,6 +28,7 @@ import {combineLatest, map, mergeMap} from 'rxjs';
|
||||
FormatPurePipeModule,
|
||||
IsTodayPipeModule,
|
||||
RouterModule,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class MailPageComponent {
|
||||
@@ -31,15 +36,24 @@ export class MailPageComponent {
|
||||
|
||||
readonly mailService = inject(MailService);
|
||||
|
||||
mailbox = this.activatedRoute.paramMap.pipe(map(parameters => parameters.get('mailbox')!));
|
||||
readonly mailStorage = inject(MailStorageProvider);
|
||||
|
||||
mails = this.mailbox.pipe(
|
||||
mergeMap(mailbox =>
|
||||
this.mailService
|
||||
.listEmails(mailbox)
|
||||
.pipe(
|
||||
mergeMap(emails => combineLatest(emails.map(email => this.mailService.getEmail(mailbox, email)))),
|
||||
),
|
||||
mailIcons: Record<Exclude<MailboxTreeItem['specialUse'], undefined>, keyof typeof SCIcon> = {
|
||||
'\\Inbox': SCIcon.inbox,
|
||||
'\\All': SCIcon.all_inbox,
|
||||
'\\Archive': SCIcon.archive,
|
||||
'\\Drafts': SCIcon.drafts,
|
||||
'\\Flagged': SCIcon.flag,
|
||||
'\\Junk': SCIcon.folder,
|
||||
'\\Sent': SCIcon.send,
|
||||
'\\Trash': SCIcon.delete,
|
||||
};
|
||||
|
||||
mailbox = this.activatedRoute.paramMap.pipe(
|
||||
mergeMap(parameters =>
|
||||
this.mailStorage.mailboxes.pipe(
|
||||
map(mailboxes => mailboxes.folders.find(it => it.path === parameters.get('mailbox')!)!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,78 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-menu-button menu="mailboxes"></ion-menu-button>
|
||||
</ion-buttons>
|
||||
|
||||
<ion-title>Mail</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content parallax>
|
||||
<h1>Inbox</h1>
|
||||
|
||||
@if (mails | async; as mails) {
|
||||
<ion-list>
|
||||
@for (mail of mails; track mail) {
|
||||
<ion-item
|
||||
[routerLink]="['/mail', mailbox | async, mail.id]"
|
||||
[class.unread]="!mail.flags.has('\\Seen')"
|
||||
>
|
||||
<div slot="start" class="avatar">
|
||||
@if (mail.from; as from) {
|
||||
<div>
|
||||
{{ (from.value.name || from.value.address)?.charAt(0)?.toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ion-label>
|
||||
<h2>{{ mail.from.value.name || mail.from.value.address }}</h2>
|
||||
@if (mail.subject) {
|
||||
<p>{{ mail.subject.value }}</p>
|
||||
}
|
||||
</ion-label>
|
||||
<ion-note slot="end">
|
||||
@if (mail.date.value | dfnsIsToday) {
|
||||
{{ mail.date.value | dfnsFormatPure: 'p' }}
|
||||
} @else {
|
||||
{{ mail.date.value | dfnsFormatPure: 'P' }}
|
||||
}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
<ion-split-pane contentId="mailbox-content">
|
||||
<ion-menu contentId="mailbox-content" menuId="mailboxes">
|
||||
@if (mailStorage.mailboxes | async; as mailboxes) {
|
||||
<ion-list>
|
||||
@for (folder of mailboxes.folders; track folder) {
|
||||
<ion-item
|
||||
[routerLink]="['/mail', folder.path]"
|
||||
[class.active]="folder.path === (mailbox | async)?.path"
|
||||
>
|
||||
@if (folder.specialUse) {
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[fill]="folder.path === (mailbox | async)?.path"
|
||||
[name]="mailIcons[folder.specialUse]"
|
||||
></ion-icon>
|
||||
<ion-label>{{ 'mail.mailboxes.' + folder.specialUse | translate }}</ion-label>
|
||||
} @else {
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[fill]="folder.path === (mailbox | async)?.path"
|
||||
name="folder"
|
||||
></ion-icon>
|
||||
<ion-label>{{ folder.name }}</ion-label>
|
||||
}
|
||||
</ion-item>
|
||||
}
|
||||
</ion-list>
|
||||
}
|
||||
</ion-list>
|
||||
} @else {
|
||||
<div>Loading...</div>
|
||||
}
|
||||
</ion-menu>
|
||||
|
||||
<ion-router-outlet id="mailbox-content"></ion-router-outlet>
|
||||
<!--@if (mails | async; as mails) {
|
||||
<ion-list>
|
||||
@for (mail of mails; track mail) {
|
||||
<ion-item
|
||||
[routerLink]="['/mail', mailbox | async, mail.id]"
|
||||
[class.unread]="!mail.flags.has('\\Seen')"
|
||||
>
|
||||
<div slot="start" class="avatar">
|
||||
@if (mail.from; as from) {
|
||||
<div>
|
||||
{{ (from.value.name || from.value.address)?.charAt(0)?.toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ion-label>
|
||||
<h2>{{ mail.from.value.name || mail.from.value.address }}</h2>
|
||||
@if (mail.subject) {
|
||||
<p>{{ mail.subject.value }}</p>
|
||||
}
|
||||
</ion-label>
|
||||
<ion-note slot="end">
|
||||
@if (mail.date.value | dfnsIsToday) {
|
||||
{{ mail.date.value | dfnsFormatPure: 'p' }}
|
||||
} @else {
|
||||
{{ mail.date.value | dfnsFormatPure: 'P' }}
|
||||
}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
}
|
||||
</ion-list>
|
||||
} @else {
|
||||
<div>Loading...</div>
|
||||
}-->
|
||||
</ion-split-pane>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
color: var(--ion-color-light-contrast);
|
||||
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-inline: var(--spacing-md);
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
|
||||
ion-item.unread h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ion-item p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
ion-list {
|
||||
margin: var(--spacing-md);
|
||||
border-radius: var(--border-radius-default);
|
||||
}
|
||||
|
||||
ion-split-pane {
|
||||
--border: none;
|
||||
}
|
||||
|
||||
ion-menu,
|
||||
ion-menu::part(container),
|
||||
ion-menu::part(backdrop) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
ion-menu:not(.menu-pane-visible) {
|
||||
&::part(container) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
> ion-list {
|
||||
height: 100%;
|
||||
margin-inline-end: var(--spacing-xl);
|
||||
box-shadow: rgba(0 0 0 / 18%) 4px 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,193 @@
|
||||
/* eslint-disable unicorn/no-useless-undefined */
|
||||
import {Injectable} from '@angular/core';
|
||||
import {StorageProvider} from '../storage/storage.provider';
|
||||
import {Email} from 'postal-mime';
|
||||
import {Capacitor} from '@capacitor/core';
|
||||
import {SecureStoragePlugin} from 'capacitor-secure-storage-plugin';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
catchError,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
from,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
take,
|
||||
firstValueFrom,
|
||||
} from 'rxjs';
|
||||
import {Email, MailboxTreeRoot} from './schema';
|
||||
import equal from 'fast-deep-equal';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class MailStorageProvider {
|
||||
constructor(readonly storageProvider: StorageProvider) {}
|
||||
static readonly DB_NAME = 'mail';
|
||||
|
||||
private storageKey(...path: string[]): string {
|
||||
return ['mail', ...path.map(encodeURIComponent)].join('/');
|
||||
}
|
||||
static readonly MAILBOX_STORE_NAME = 'mailboxes';
|
||||
|
||||
async get(mailbox: string, id: string): Promise<Email | undefined> {
|
||||
try {
|
||||
return await this.storageProvider.get<Email>(this.storageKey(mailbox, id));
|
||||
} catch (error) {
|
||||
console.info('Mail not found in storage', error);
|
||||
return undefined;
|
||||
static readonly EMAIL_STORE_NAME = 'emails';
|
||||
|
||||
static readonly CREDENTIALS_KEY = 'email-credentials';
|
||||
|
||||
database = defer(() => {
|
||||
const request = indexedDB.open(MailStorageProvider.DB_NAME, 1);
|
||||
return merge(
|
||||
fromEvent(request, 'upgradeneeded').pipe(
|
||||
map(event => {
|
||||
const database = (event.target as IDBOpenDBRequest).result;
|
||||
database.createObjectStore(MailStorageProvider.MAILBOX_STORE_NAME, {keyPath: 'path'});
|
||||
const mailStore = database.createObjectStore(MailStorageProvider.EMAIL_STORE_NAME, {keyPath: 'id'});
|
||||
mailStore.createIndex('mailbox', 'mailbox', {unique: false});
|
||||
mailStore.createIndex('flags', 'flags', {unique: false, multiEntry: true});
|
||||
mailStore.createIndex('date', 'date', {unique: false});
|
||||
return database;
|
||||
}),
|
||||
),
|
||||
fromEvent(request, 'success').pipe(
|
||||
take(1),
|
||||
map(event => (event.target as IDBOpenDBRequest).result),
|
||||
),
|
||||
fromEvent(request, 'error').pipe(
|
||||
take(1),
|
||||
map(event => {
|
||||
throw (event.target as IDBOpenDBRequest).error;
|
||||
}),
|
||||
),
|
||||
fromEvent(request, 'blocked').pipe(
|
||||
take(1),
|
||||
map(() => {
|
||||
throw new Error('Database blocked');
|
||||
}),
|
||||
),
|
||||
);
|
||||
}).pipe(shareReplay(1));
|
||||
|
||||
private mailboxesChanged = new BehaviorSubject<void>(undefined);
|
||||
|
||||
mailboxes: Observable<MailboxTreeRoot> = this.database.pipe(
|
||||
mergeMap(database =>
|
||||
merge(this.mailboxesChanged, of(undefined)).pipe(
|
||||
mergeMap(() => {
|
||||
const request: IDBRequest<MailboxTreeRoot> = database
|
||||
.transaction([MailStorageProvider.MAILBOX_STORE_NAME], 'readonly')
|
||||
.objectStore(MailStorageProvider.MAILBOX_STORE_NAME)
|
||||
.get('');
|
||||
return merge(
|
||||
fromEvent(request, 'success').pipe(map(() => request.result as MailboxTreeRoot)),
|
||||
fromEvent(request, 'error').pipe(
|
||||
map(event => {
|
||||
throw (event.target as IDBRequest).error;
|
||||
}),
|
||||
),
|
||||
).pipe(take(1));
|
||||
}),
|
||||
),
|
||||
),
|
||||
distinctUntilChanged((a, b) => equal(a, b)),
|
||||
);
|
||||
|
||||
async setMailboxes(root: MailboxTreeRoot | undefined): Promise<void> {
|
||||
const database = await firstValueFrom(this.database);
|
||||
const transaction = database.transaction([MailStorageProvider.MAILBOX_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(MailStorageProvider.MAILBOX_STORE_NAME);
|
||||
store.clear();
|
||||
if (root !== undefined) {
|
||||
store.add(root);
|
||||
}
|
||||
await firstValueFrom(
|
||||
merge(
|
||||
fromEvent(transaction, 'complete').pipe(
|
||||
map(() => {
|
||||
this.mailboxesChanged.next();
|
||||
}),
|
||||
),
|
||||
fromEvent(transaction, 'error').pipe(
|
||||
map(event => {
|
||||
throw (event.target as IDBRequest).error;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async put(mailbox: string, id: string, email: Email): Promise<void> {
|
||||
await this.storageProvider.put(this.storageKey(mailbox, id), email);
|
||||
private credentialsChanged = new BehaviorSubject<void>(undefined);
|
||||
|
||||
credentials: Observable<string | undefined> = this.credentialsChanged.pipe(
|
||||
mergeMap(() => {
|
||||
return Capacitor.isNativePlatform()
|
||||
? from(SecureStoragePlugin.get({key: MailStorageProvider.CREDENTIALS_KEY})).pipe(
|
||||
map(({value}) => value),
|
||||
catchError(() => of(undefined)),
|
||||
)
|
||||
: of(localStorage.getItem(MailStorageProvider.CREDENTIALS_KEY) ?? undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
async setCredentials(credentials: string | undefined): Promise<void> {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await (credentials === undefined
|
||||
? SecureStoragePlugin.remove({key: MailStorageProvider.CREDENTIALS_KEY})
|
||||
: SecureStoragePlugin.set({key: MailStorageProvider.CREDENTIALS_KEY, value: credentials}));
|
||||
} else {
|
||||
if (credentials === undefined) {
|
||||
localStorage.removeItem(MailStorageProvider.CREDENTIALS_KEY);
|
||||
} else {
|
||||
localStorage.setItem(MailStorageProvider.CREDENTIALS_KEY, credentials);
|
||||
}
|
||||
}
|
||||
this.credentialsChanged.next();
|
||||
}
|
||||
|
||||
async delete(mailbox: string, id: string): Promise<void> {
|
||||
await this.storageProvider.delete(this.storageKey(mailbox, id));
|
||||
private emailChanged = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
private mailboxContentChanged = new BehaviorSubject<Set<string>>(new Set());
|
||||
|
||||
getEmails(mailbox: string, page: number) {
|
||||
return this.mailboxContentChanged.pipe(
|
||||
filter(it => it.has(mailbox)),
|
||||
startWith(undefined),
|
||||
mergeMap(() => this.database),
|
||||
mergeMap(database => {
|
||||
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
|
||||
|
||||
store.index('');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async setEmail(email: Email | Email[]): 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);
|
||||
|
||||
const mailboxesAffected = new Set<string>();
|
||||
const emailsAffected = new Set<string>();
|
||||
|
||||
for (const it of Array.isArray(email) ? email : [email]) {
|
||||
mailboxesAffected.add(it.mailbox);
|
||||
emailsAffected.add(it.id);
|
||||
store.put(it);
|
||||
}
|
||||
|
||||
await firstValueFrom(
|
||||
merge(
|
||||
fromEvent(transaction, 'complete').pipe(
|
||||
map(() => {
|
||||
this.mailboxContentChanged.next(mailboxesAffected);
|
||||
this.emailChanged.next(emailsAffected);
|
||||
}),
|
||||
),
|
||||
fromEvent(transaction, 'error').pipe(
|
||||
map(event => {
|
||||
throw (event.target as IDBRequest).error;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,33 +12,45 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import {RouterModule, UrlSegment, UrlSegmentGroup, UrlTree} from '@angular/router';
|
||||
import {Router, RouterModule} from '@angular/router';
|
||||
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(
|
||||
map(isLoggedIn => (isLoggedIn ? true : router.createUrlTree(['/mail-login']))),
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: 'mail',
|
||||
path: 'mail-login',
|
||||
data: {redirectTo: ['/mail']},
|
||||
loadComponent: () => import('./mail-login.component').then(m => m.MailLoginComponent),
|
||||
canActivateChild: [
|
||||
() => {
|
||||
if (inject(MailService).isLoggedIn()) {
|
||||
return true;
|
||||
} else {
|
||||
return new UrlTree(new UrlSegmentGroup([new UrlSegment('/mail', {})], {}));
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'mail',
|
||||
loadComponent: () => import('./mail-page.component').then(m => m.MailPageComponent),
|
||||
canActivate: [mailLoginGuard],
|
||||
canActivateChild: [mailLoginGuard],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'INBOX',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: ':mailbox',
|
||||
loadComponent: () => import('./mail-page.component').then(m => m.MailPageComponent),
|
||||
loadComponent: () => import('./mailbox-page.component').then(m => m.MailboxPageComponent),
|
||||
},
|
||||
{
|
||||
path: ':mailbox/:id',
|
||||
loadComponent: () => import('./mail-detail.component').then(m => m.MailDetailComponent),
|
||||
loadComponent: () => import('./mailbox-page.component').then(m => m.MailboxPageComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Observable, map, tap, mergeMap, of, forkJoin, catchError} from 'rxjs';
|
||||
import {Observable, map, tap, mergeMap, of, forkJoin, catchError, BehaviorSubject} from 'rxjs';
|
||||
import PostalMime from 'postal-mime';
|
||||
import {ContentInfo, SignedData} from 'pkijs';
|
||||
import {RawEmail, Email, SignedValue, RawEmailBodyStructure, Signature} from './schema';
|
||||
import {RawEmail, Email, SignedValue, RawEmailBodyStructure, Signature, MailboxTreeRoot} from './schema';
|
||||
import {z} from 'zod';
|
||||
import {MailStorageProvider} from './mail-storage.provider';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
|
||||
function value(value: undefined): undefined;
|
||||
function value<T>(value: T): SignedValue<T>;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function value<T>(value: T | undefined): SignedValue<T> | undefined {
|
||||
return value === undefined ? undefined : {value};
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class MailService {
|
||||
constructor(private httpClient: HttpClient) {}
|
||||
isLoggedIn = this.mailStorage.credentials.pipe(map(it => it !== undefined));
|
||||
|
||||
manualSync = new BehaviorSubject<void>(undefined);
|
||||
|
||||
constructor(
|
||||
private httpClient: HttpClient,
|
||||
private mailStorage: MailStorageProvider,
|
||||
) {
|
||||
this.mailStorage.credentials
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
mergeMap(credentials => this.manualSync.pipe(map(() => credentials))),
|
||||
mergeMap(credentials => {
|
||||
return credentials === undefined
|
||||
? of()
|
||||
: this.listMailboxes(credentials).pipe(
|
||||
mergeMap(mailboxes => this.mailStorage.setMailboxes(mailboxes)),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
private request<T>(options: {
|
||||
method?: string;
|
||||
path?: string[];
|
||||
options?: Record<string, string>;
|
||||
responseType?: 'json' | 'arraybuffer';
|
||||
credentials?: string;
|
||||
}): Observable<T> {
|
||||
return this.httpClient.request<T>(
|
||||
options.method ?? 'GET',
|
||||
@@ -31,23 +58,48 @@ export class MailService {
|
||||
}`,
|
||||
{
|
||||
responseType: options.responseType as 'json',
|
||||
headers: {authorization: `Basic ${btoa('test:123')}`},
|
||||
headers: options.credentials ? {authorization: `Basic ${options.credentials}`} : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
isLoggedIn() {
|
||||
return false;
|
||||
login(username: string, password: string): Observable<boolean> {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
return this.request<unknown>({
|
||||
path: [],
|
||||
options: {},
|
||||
responseType: 'json',
|
||||
credentials,
|
||||
}).pipe(
|
||||
map(() => true),
|
||||
catchError(error => {
|
||||
if (error.status === 401) {
|
||||
return of(false);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
tap(success => {
|
||||
if (success) {
|
||||
this.mailStorage.setCredentials(credentials);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
listMailboxes(): Observable<string[]> {
|
||||
return this.request<string[]>({});
|
||||
logout() {
|
||||
this.mailStorage.setCredentials(undefined);
|
||||
this.mailStorage.setMailboxes(undefined);
|
||||
}
|
||||
|
||||
listMailboxes(credentials: string): Observable<MailboxTreeRoot> {
|
||||
return this.request<string[]>({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it)));
|
||||
}
|
||||
|
||||
private listRawEmails(mailbox: string, since?: Date): Observable<string[]> {
|
||||
return this.request<unknown>({
|
||||
path: [mailbox],
|
||||
options: since !== undefined ? {since: since.valueOf().toString()} : undefined,
|
||||
options: since === undefined ? undefined : {since: since.valueOf().toString()},
|
||||
}).pipe(
|
||||
mergeMap(it => z.array(z.string()).parseAsync(it)),
|
||||
tap(console.log),
|
||||
@@ -94,6 +146,9 @@ export class MailService {
|
||||
|
||||
function signed(value: undefined): undefined;
|
||||
function signed<T>(value: T): SignedValue<T>;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function signed<T>(value: T | undefined): SignedValue<T> | undefined {
|
||||
return value === undefined
|
||||
? undefined
|
||||
@@ -108,6 +163,7 @@ export class MailService {
|
||||
|
||||
const result: Email = {
|
||||
id: email.seq,
|
||||
mailbox,
|
||||
subject: signedEmail.subject ? signed(signedEmail.subject) : value(email.envelope.subject),
|
||||
flags: new Set<string>(), //TODO
|
||||
from: signed({
|
||||
|
||||
45
frontend/app/src/app/modules/mail/mailbox-page.component.ts
Normal file
45
frontend/app/src/app/modules/mail/mailbox-page.component.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {MailService} from './mail.service';
|
||||
import {MailStorageProvider} from './mail-storage.provider';
|
||||
import {mergeMap, map, combineLatest} from 'rxjs';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'stapps-mailbox-page',
|
||||
templateUrl: 'mailbox-page.html',
|
||||
styleUrl: 'mailbox-page.scss',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [IonicModule, IonIconModule, TranslateModule, AsyncPipe],
|
||||
})
|
||||
export class MailboxPageComponent {
|
||||
readonly activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
readonly mailService = inject(MailService);
|
||||
|
||||
readonly mailStorage = inject(MailStorageProvider);
|
||||
|
||||
mailbox = this.activatedRoute.paramMap.pipe(
|
||||
mergeMap(parameters =>
|
||||
this.mailStorage.mailboxes.pipe(
|
||||
map(mailboxes => mailboxes.folders.find(it => it.path === parameters.get('mailbox')!)!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
mails = this.mailbox.pipe(
|
||||
mergeMap(mailbox =>
|
||||
this.mailService
|
||||
.listEmails(mailbox.path)
|
||||
.pipe(
|
||||
mergeMap(emails =>
|
||||
combineLatest(emails.map(email => this.mailService.getEmail(mailbox.path, email))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
11
frontend/app/src/app/modules/mail/mailbox-page.html
Normal file
11
frontend/app/src/app/modules/mail/mailbox-page.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<h1>
|
||||
@if (mailbox | async; as mailbox) {
|
||||
@if (mailbox.specialUse) {
|
||||
{{ 'mail.mailboxes.' + mailbox.specialUse | translate }}
|
||||
} @else {
|
||||
{{ mailbox.name }}
|
||||
}
|
||||
} @else {
|
||||
<ion-skeleton-text animated style="width: 100px; height: 20px"></ion-skeleton-text>
|
||||
}
|
||||
</h1>
|
||||
28
frontend/app/src/app/modules/mail/mailbox-page.scss
Normal file
28
frontend/app/src/app/modules/mail/mailbox-page.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
|
||||
color: var(--ion-color-light-contrast);
|
||||
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-inline: var(--spacing-md);
|
||||
color: var(--ion-color-primary-contrast);
|
||||
}
|
||||
|
||||
ion-item.unread h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ion-item p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -51,6 +51,35 @@ export const RawEmail = z.object({
|
||||
|
||||
export type RawEmail = z.infer<typeof RawEmail>;
|
||||
|
||||
const MailboxTreeItemBase = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
delimiter: z.string(),
|
||||
flags: z.array(z.string()).or(z.object({})),
|
||||
specialUse: z.optional(
|
||||
z.enum(['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash', '\\Inbox']),
|
||||
),
|
||||
listed: z.boolean(),
|
||||
subscribed: z.boolean(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type MailboxTreeItem = z.infer<typeof MailboxTreeItemBase> & {
|
||||
folders?: MailboxTreeItem[];
|
||||
};
|
||||
|
||||
export const MailboxTreeItem: z.ZodType<MailboxTreeItem> = MailboxTreeItemBase.extend({
|
||||
folders: z.optional(z.lazy(() => z.array(MailboxTreeItem))),
|
||||
});
|
||||
|
||||
export const MailboxTreeRoot = z.object({
|
||||
path: z.literal('').default(''),
|
||||
root: z.literal(true),
|
||||
folders: z.array(MailboxTreeItem),
|
||||
});
|
||||
|
||||
export type MailboxTreeRoot = z.infer<typeof MailboxTreeRoot>;
|
||||
|
||||
export interface Signature {
|
||||
type: 'pkcs7';
|
||||
valid: boolean;
|
||||
@@ -83,6 +112,7 @@ export type EmailAttachment = EmailAttachmentRemote | EmailAttachmentLocal;
|
||||
|
||||
export interface Email {
|
||||
id: string;
|
||||
mailbox: string;
|
||||
flags: Set<string>;
|
||||
subject?: SignedValue<string>;
|
||||
date: SignedValue<Date>;
|
||||
|
||||
@@ -395,7 +395,26 @@
|
||||
"FROM": "von",
|
||||
"SENDER": "Absender",
|
||||
"TO": "an",
|
||||
"DATE": "Datum"
|
||||
"DATE": "Datum",
|
||||
"login": {
|
||||
"TITLE": "E-Mail Login",
|
||||
"LOGIN": "Login",
|
||||
"PLACEHOLDER_USERNAME": "Nutzername",
|
||||
"PLACEHOLDER_PASSWORD": "Passwort",
|
||||
"error": {
|
||||
"INVALID_CREDENTIALS": "ungültige Zugangsdaten"
|
||||
}
|
||||
},
|
||||
"mailboxes": {
|
||||
"\\Inbox": "Posteingang",
|
||||
"\\All": "Alle",
|
||||
"\\Archive": "Archiv",
|
||||
"\\Drafts": "Entwürfe",
|
||||
"\\Flagged": "Markiert",
|
||||
"\\Junk": "Spam",
|
||||
"\\Sent": "Gesendet",
|
||||
"\\Trash": "Papierkorb"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"context": {
|
||||
|
||||
@@ -395,7 +395,26 @@
|
||||
"FROM": "from",
|
||||
"SENDER": "sender",
|
||||
"TO": "to",
|
||||
"DATE": "date"
|
||||
"DATE": "date",
|
||||
"login": {
|
||||
"TITLE": "email login",
|
||||
"LOGIN": "login",
|
||||
"PLACEHOLDER_USERNAME": "username",
|
||||
"PLACEHOLDER_PASSWORD": "password",
|
||||
"error": {
|
||||
"INVALID_CREDENTIALS": "invalid credentials"
|
||||
}
|
||||
},
|
||||
"mailboxes": {
|
||||
"\\Inbox": "Posteingang",
|
||||
"\\All": "Alle",
|
||||
"\\Archive": "Archiv",
|
||||
"\\Drafts": "Entwürfe",
|
||||
"\\Flagged": "Markiert",
|
||||
"\\Junk": "Spam",
|
||||
"\\Sent": "Gesendet",
|
||||
"\\Trash": "Papierkorb"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"context": {
|
||||
|
||||
Binary file not shown.
103
pnpm-lock.yaml
generated
103
pnpm-lock.yaml
generated
@@ -330,9 +330,6 @@ importers:
|
||||
prettier:
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
swagger-jsdoc:
|
||||
specifier: ^6.2.8
|
||||
version: 6.2.8(openapi-types@12.1.0)
|
||||
tsup:
|
||||
specifier: 6.7.0
|
||||
version: 6.7.0(ts-node@10.9.2)(typescript@5.4.2)
|
||||
@@ -2723,38 +2720,6 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/@apidevtools/json-schema-ref-parser@9.1.2:
|
||||
resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==}
|
||||
dependencies:
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
'@types/json-schema': 7.0.11
|
||||
call-me-maybe: 1.0.2
|
||||
js-yaml: 4.1.0
|
||||
dev: true
|
||||
|
||||
/@apidevtools/openapi-schemas@2.1.0:
|
||||
resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/@apidevtools/swagger-methods@3.0.2:
|
||||
resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==}
|
||||
dev: true
|
||||
|
||||
/@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.0):
|
||||
resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==}
|
||||
peerDependencies:
|
||||
openapi-types: '>=7'
|
||||
dependencies:
|
||||
'@apidevtools/json-schema-ref-parser': 9.1.2
|
||||
'@apidevtools/openapi-schemas': 2.1.0
|
||||
'@apidevtools/swagger-methods': 3.0.2
|
||||
'@jsdevtools/ono': 7.1.3
|
||||
call-me-maybe: 1.0.2
|
||||
openapi-types: 12.1.0
|
||||
z-schema: 5.0.5
|
||||
dev: true
|
||||
|
||||
/@awesome-cordova-plugins/calendar@6.6.0(@awesome-cordova-plugins/core@6.6.0)(rxjs@7.8.1):
|
||||
resolution: {integrity: sha512-NobAl4xvmq2zBeOnLI+pqRVpC66p7OpCwd3jzrQ26h8kqhr0o5wqaNcWN6WBjmgD+/AInVnLUzsziL2QpcmD7g==}
|
||||
peerDependencies:
|
||||
@@ -7103,10 +7068,6 @@ packages:
|
||||
'@jridgewell/resolve-uri': 3.1.1
|
||||
'@jridgewell/sourcemap-codec': 1.4.15
|
||||
|
||||
/@jsdevtools/ono@7.1.3:
|
||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||
dev: true
|
||||
|
||||
/@leichtgewicht/ip-codec@2.0.5:
|
||||
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
|
||||
dev: true
|
||||
@@ -10036,10 +9997,6 @@ packages:
|
||||
set-function-length: 1.2.2
|
||||
dev: true
|
||||
|
||||
/call-me-maybe@1.0.2:
|
||||
resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==}
|
||||
dev: true
|
||||
|
||||
/callsite@1.0.0:
|
||||
resolution: {integrity: sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==}
|
||||
dev: true
|
||||
@@ -10486,11 +10443,6 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/commander@6.2.0:
|
||||
resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==}
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/commander@6.2.1:
|
||||
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -15655,10 +15607,6 @@ packages:
|
||||
resolution: {integrity: sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==}
|
||||
dev: true
|
||||
|
||||
/lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
dev: true
|
||||
|
||||
/lodash.ismatch@4.4.0:
|
||||
resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==}
|
||||
dev: true
|
||||
@@ -15674,10 +15622,6 @@ packages:
|
||||
/lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
/lodash.mergewith@4.6.2:
|
||||
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
|
||||
dev: true
|
||||
|
||||
/lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
dev: true
|
||||
@@ -17411,6 +17355,7 @@ packages:
|
||||
|
||||
/openapi-types@12.1.0:
|
||||
resolution: {integrity: sha512-XpeCy01X6L5EpP+6Hc3jWN7rMZJ+/k1lwki/kTmWzbVhdPie3jd5O2ZtedEx8Yp58icJ0osVldLMrTB/zslQXA==}
|
||||
dev: false
|
||||
|
||||
/opencollective-postinstall@2.0.3:
|
||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||
@@ -20407,30 +20352,6 @@ packages:
|
||||
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
|
||||
dev: true
|
||||
|
||||
/swagger-jsdoc@6.2.8(openapi-types@12.1.0):
|
||||
resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
commander: 6.2.0
|
||||
doctrine: 3.0.0
|
||||
glob: 7.1.6
|
||||
lodash.mergewith: 4.6.2
|
||||
swagger-parser: 10.0.3(openapi-types@12.1.0)
|
||||
yaml: 2.0.0-1
|
||||
transitivePeerDependencies:
|
||||
- openapi-types
|
||||
dev: true
|
||||
|
||||
/swagger-parser@10.0.3(openapi-types@12.1.0):
|
||||
resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==}
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
'@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.0)
|
||||
transitivePeerDependencies:
|
||||
- openapi-types
|
||||
dev: true
|
||||
|
||||
/swiper@8.4.5:
|
||||
resolution: {integrity: sha512-zveyEFBBv4q1sVkbJHnuH4xCtarKieavJ4SxP0QEHvdpPLJRuD7j/Xg38IVVLbp7Db6qrPsLUePvxohYx39Agw==}
|
||||
engines: {node: '>= 4.7.0'}
|
||||
@@ -21573,11 +21494,6 @@ packages:
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
dev: true
|
||||
|
||||
/validator@13.12.0:
|
||||
resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
dev: true
|
||||
|
||||
/vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -22255,11 +22171,6 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/yaml@2.0.0-1:
|
||||
resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==}
|
||||
engines: {node: '>= 6'}
|
||||
dev: true
|
||||
|
||||
/yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -22356,18 +22267,6 @@ packages:
|
||||
engines: {node: '>=12.20'}
|
||||
dev: true
|
||||
|
||||
/z-schema@5.0.5:
|
||||
resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
lodash.get: 4.4.2
|
||||
lodash.isequal: 4.5.0
|
||||
validator: 13.12.0
|
||||
optionalDependencies:
|
||||
commander: 9.5.0
|
||||
dev: true
|
||||
|
||||
/zepto@1.2.0:
|
||||
resolution: {integrity: sha512-C1x6lfvBICFTQIMgbt3JqMOno3VOtkWat/xEakLTOurskYIHPmzJrzd1e8BnmtdDVJlGuk5D+FxyCA8MPmkIyA==}
|
||||
dev: true
|
||||
|
||||
Reference in New Issue
Block a user