feat: more mail stuff

This commit is contained in:
2024-10-09 18:10:59 +02:00
parent 6584787fbd
commit 9d6a9dc60c
17 changed files with 679 additions and 216 deletions

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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')!)!),
),
),
);
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}),
),
),
);
}
}

View File

@@ -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),
},
],
},

View File

@@ -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({

View 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))),
),
),
),
);
}

View 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>

View 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;
}

View File

@@ -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>;

View File

@@ -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": {

View File

@@ -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": {

103
pnpm-lock.yaml generated
View File

@@ -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