mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-05-03 19:49:19 +00:00
feat: email client prototype
This commit is contained in:
2
backend/mail-plugin/app.js
Normal file
2
backend/mail-plugin/app.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import './lib/cli.js';
|
||||||
68
backend/mail-plugin/package.json
Normal file
68
backend/mail-plugin/package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "@openstapps/mail-plugin",
|
||||||
|
"description": "Mail Plugin",
|
||||||
|
"version": "3.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"author": "Thea Schöbl",
|
||||||
|
"bin": "app.js",
|
||||||
|
"files": [
|
||||||
|
"app.js",
|
||||||
|
"lib",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"Dockerfile"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup-node --dts",
|
||||||
|
"dev": "tsup-node --watch --onSuccess \"pnpm run start\"",
|
||||||
|
"start": "node app.js",
|
||||||
|
"deploy": "pnpm --prod --filter=@openstapps/minimal-plugin deploy ../../.deploy/minimal-plugin",
|
||||||
|
"format": "prettier . -c --ignore-path ../../.gitignore",
|
||||||
|
"format:fix": "prettier --write . --ignore-path ../../.gitignore",
|
||||||
|
"lint": "eslint --ext .ts src/",
|
||||||
|
"lint:fix": "eslint --fix --ext .ts src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@openstapps/core": "workspace:*",
|
||||||
|
"@openstapps/core-tools": "workspace:*",
|
||||||
|
"@openstapps/logger": "workspace:*",
|
||||||
|
"commander": "10.0.0",
|
||||||
|
"dotenv": "16.4.5",
|
||||||
|
"express": "4.18.2",
|
||||||
|
"imapflow": "1.0.162",
|
||||||
|
"mailparser": "3.7.1",
|
||||||
|
"node-forge": "1.3.1",
|
||||||
|
"nodemailer": "6.9.14",
|
||||||
|
"ts-node": "10.9.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@openstapps/eslint-config": "workspace:*",
|
||||||
|
"@openstapps/prettier-config": "workspace:*",
|
||||||
|
"@openstapps/tsconfig": "workspace:*",
|
||||||
|
"@types/express": "4.17.17",
|
||||||
|
"@types/imapflow": "1.0.18",
|
||||||
|
"@types/mailparser": "3.4.4",
|
||||||
|
"@types/node": "18.15.3",
|
||||||
|
"@types/node-forge": "1.3.11",
|
||||||
|
"@types/nodemailer": "6.4.15",
|
||||||
|
"tsup": "6.7.0",
|
||||||
|
"typescript": "5.4.2"
|
||||||
|
},
|
||||||
|
"tsup": {
|
||||||
|
"entry": [
|
||||||
|
"src/cli.ts"
|
||||||
|
],
|
||||||
|
"sourcemap": true,
|
||||||
|
"clean": true,
|
||||||
|
"format": "esm",
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"prettier": "@openstapps/prettier-config",
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"@openstapps"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backend/mail-plugin/src/cli.ts
Normal file
116
backend/mail-plugin/src/cli.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {config} from 'dotenv';
|
||||||
|
import {ImapFlow} from 'imapflow';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
config({path: '.env.local'});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 4000;
|
||||||
|
|
||||||
|
app.use(async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64')
|
||||||
|
.toString('utf8')
|
||||||
|
.split(':');
|
||||||
|
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: 'imap.server.uni-frankfurt.de',
|
||||||
|
port: 993,
|
||||||
|
secure: true,
|
||||||
|
emitLogs: false,
|
||||||
|
auth: {user, pass},
|
||||||
|
});
|
||||||
|
response.locals.client = client;
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
response.on('finish', async () => {
|
||||||
|
await client.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
response.status(401).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', async (_request, response) => {
|
||||||
|
const result = await response.locals.client.listTree();
|
||||||
|
response.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/:mailbox', async (request, response) => {
|
||||||
|
try {
|
||||||
|
await response.locals.client.mailboxOpen(request.params.mailbox);
|
||||||
|
const since = Number(request.query.since) || undefined;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
response.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
response.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/:mailbox/:id', async (request, response) => {
|
||||||
|
try {
|
||||||
|
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: true,
|
||||||
|
});
|
||||||
|
response.json({
|
||||||
|
bodyStructure: message.bodyStructure,
|
||||||
|
labels: [...(message.labels ?? [])],
|
||||||
|
flags: [...(message.flags ?? [])],
|
||||||
|
envelope: message.envelope,
|
||||||
|
seq: message.seq,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
response.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/:mailbox/:id/:part', async (request, response) => {
|
||||||
|
try {
|
||||||
|
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: true});
|
||||||
|
if (request.query.raw) {
|
||||||
|
const message = await response.locals.client.fetchOne(request.params.id, {
|
||||||
|
bodyParts: [`${request.params.part}.mime`, request.params.part],
|
||||||
|
});
|
||||||
|
|
||||||
|
response.write(message.bodyParts.get(`${request.params.part}.mime`));
|
||||||
|
response.write(message.bodyParts.get(request.params.part));
|
||||||
|
|
||||||
|
response.end();
|
||||||
|
} else {
|
||||||
|
const message = await response.locals.client.download(request.params.id, request.params.part);
|
||||||
|
message.content.on('data', chunk => {
|
||||||
|
response.write(chunk);
|
||||||
|
});
|
||||||
|
message.content.on('end', () => {
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
response.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server listening on port ${port}`);
|
||||||
|
});
|
||||||
9
backend/mail-plugin/src/types.d.ts
vendored
Normal file
9
backend/mail-plugin/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {ImapFlow} from 'imapflow';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Locals {
|
||||||
|
client: ImapFlow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/mail-plugin/tsconfig.json
Normal file
4
backend/mail-plugin/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@openstapps/tsconfig",
|
||||||
|
"exclude": ["lib", "app.js"]
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
"@openstapps/core": "workspace:*",
|
"@openstapps/core": "workspace:*",
|
||||||
"@transistorsoft/capacitor-background-fetch": "5.2.0",
|
"@transistorsoft/capacitor-background-fetch": "5.2.0",
|
||||||
"@types/dom-view-transitions": "1.0.4",
|
"@types/dom-view-transitions": "1.0.4",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
"capacitor-secure-storage-plugin": "0.9.0",
|
"capacitor-secure-storage-plugin": "0.9.0",
|
||||||
"cordova-plugin-calendar": "5.1.6",
|
"cordova-plugin-calendar": "5.1.6",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
@@ -98,6 +99,8 @@
|
|||||||
"geojson": "0.5.0",
|
"geojson": "0.5.0",
|
||||||
"ionic-appauth": "0.9.0",
|
"ionic-appauth": "0.9.0",
|
||||||
"jsonpath-plus": "6.0.1",
|
"jsonpath-plus": "6.0.1",
|
||||||
|
"libbase64": "^1.3.0",
|
||||||
|
"libqp": "^2.1.0",
|
||||||
"maplibre-gl": "4.0.2",
|
"maplibre-gl": "4.0.2",
|
||||||
"material-symbols": "0.17.1",
|
"material-symbols": "0.17.1",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
@@ -106,11 +109,14 @@
|
|||||||
"ngx-markdown": "17.1.1",
|
"ngx-markdown": "17.1.1",
|
||||||
"ngx-moment": "6.0.2",
|
"ngx-moment": "6.0.2",
|
||||||
"opening_hours": "3.8.0",
|
"opening_hours": "3.8.0",
|
||||||
|
"pkijs": "3.1.0",
|
||||||
"pmtiles": "3.0.3",
|
"pmtiles": "3.0.3",
|
||||||
|
"postal-mime": "2.2.5",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
"semver": "7.6.0",
|
"semver": "7.6.0",
|
||||||
"swiper": "8.4.5",
|
"swiper": "8.4.5",
|
||||||
"tslib": "2.6.2",
|
"tslib": "2.6.2",
|
||||||
|
"zod": "^3.23.8",
|
||||||
"zone.js": "0.14.4"
|
"zone.js": "0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -139,9 +145,11 @@
|
|||||||
"@ionic/cli": "7.2.0",
|
"@ionic/cli": "7.2.0",
|
||||||
"@openstapps/prettier-config": "workspace:*",
|
"@openstapps/prettier-config": "workspace:*",
|
||||||
"@openstapps/tsconfig": "workspace:*",
|
"@openstapps/tsconfig": "workspace:*",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/fontkit": "2.0.7",
|
"@types/fontkit": "2.0.7",
|
||||||
"@types/geojson": "1.0.6",
|
"@types/geojson": "1.0.6",
|
||||||
"@types/glob": "8.1.0",
|
"@types/glob": "8.1.0",
|
||||||
|
"@types/imapflow": "1.0.18",
|
||||||
"@types/jasmine": "5.1.4",
|
"@types/jasmine": "5.1.4",
|
||||||
"@types/jasminewd2": "2.0.13",
|
"@types/jasminewd2": "2.0.13",
|
||||||
"@types/jsonpath": "0.2.0",
|
"@types/jsonpath": "0.2.0",
|
||||||
@@ -154,6 +162,7 @@
|
|||||||
"@typescript-eslint/parser": "7.2.0",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"cordova-res": "0.15.4",
|
"cordova-res": "0.15.4",
|
||||||
"cypress": "13.7.0",
|
"cypress": "13.7.0",
|
||||||
|
"dompurify": "^3.1.6",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-jsdoc": "48.2.1",
|
"eslint-plugin-jsdoc": "48.2.1",
|
||||||
"eslint-plugin-prettier": "5.1.3",
|
"eslint-plugin-prettier": "5.1.3",
|
||||||
|
|||||||
@@ -15,12 +15,11 @@
|
|||||||
import {CommonModule, LocationStrategy, PathLocationStrategy, registerLocaleData} from '@angular/common';
|
import {CommonModule, LocationStrategy, PathLocationStrategy, registerLocaleData} from '@angular/common';
|
||||||
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule} from '@angular/common/http';
|
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule} from '@angular/common/http';
|
||||||
import localeDe from '@angular/common/locales/de';
|
import localeDe from '@angular/common/locales/de';
|
||||||
import {APP_INITIALIZER, NgModule} from '@angular/core';
|
import {APP_INITIALIZER, Injectable, NgModule} from '@angular/core';
|
||||||
import {BrowserModule} from '@angular/platform-browser';
|
import {BrowserModule} from '@angular/platform-browser';
|
||||||
import {RouteReuseStrategy} from '@angular/router';
|
import {RouteReuseStrategy} from '@angular/router';
|
||||||
import {IonicModule, IonicRouteStrategy, Platform} from '@ionic/angular';
|
import {IonicModule, IonicRouteStrategy, Platform} from '@ionic/angular';
|
||||||
import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core';
|
import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core';
|
||||||
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'moment/min/locales';
|
import 'moment/min/locales';
|
||||||
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
|
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
|
||||||
@@ -71,6 +70,8 @@ import {Capacitor} from '@capacitor/core';
|
|||||||
import {SplashScreen} from '@capacitor/splash-screen';
|
import {SplashScreen} from '@capacitor/splash-screen';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import {Protocol} from 'pmtiles';
|
import {Protocol} from 'pmtiles';
|
||||||
|
import {MailModule} from './modules/mail/mail.module';
|
||||||
|
import {Observable, from} from 'rxjs';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -129,12 +130,16 @@ export function initializerFactory(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Injectable({providedIn: 'root'})
|
||||||
* TODO
|
export class ImportTranslateLoader {
|
||||||
* @param http TODO
|
static translations: Record<string, () => Promise<{default: object}>> = {
|
||||||
*/
|
de: () => import('../assets/i18n/de.json'),
|
||||||
export function createTranslateLoader(http: HttpClient) {
|
en: () => import('../assets/i18n/en.json'),
|
||||||
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
|
};
|
||||||
|
|
||||||
|
getTranslation(lang: string): Observable<object> {
|
||||||
|
return from(ImportTranslateLoader.translations[lang]().then(it => it.default));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +170,7 @@ export function createTranslateLoader(http: HttpClient) {
|
|||||||
ProfilePageModule,
|
ProfilePageModule,
|
||||||
FeedbackModule,
|
FeedbackModule,
|
||||||
MapModule,
|
MapModule,
|
||||||
|
MailModule,
|
||||||
MenuModule,
|
MenuModule,
|
||||||
NavigationModule,
|
NavigationModule,
|
||||||
NewsModule,
|
NewsModule,
|
||||||
@@ -177,7 +183,7 @@ export function createTranslateLoader(http: HttpClient) {
|
|||||||
loader: {
|
loader: {
|
||||||
deps: [HttpClient],
|
deps: [HttpClient],
|
||||||
provide: TranslateLoader,
|
provide: TranslateLoader,
|
||||||
useFactory: createTranslateLoader,
|
useClass: ImportTranslateLoader,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
UtilModule,
|
UtilModule,
|
||||||
|
|||||||
10
frontend/app/src/app/modules/auth/mail-auth.service.ts
Normal file
10
frontend/app/src/app/modules/auth/mail-auth.service.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({providedIn: 'root'})
|
||||||
|
export class MailAuthService {
|
||||||
|
login(username: string, password: string) {
|
||||||
|
navigator.credentials.store(new PasswordCredential({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {}
|
||||||
|
}
|
||||||
58
frontend/app/src/app/modules/mail/mail-detail.component.ts
Normal file
58
frontend/app/src/app/modules/mail/mail-detail.component.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
|
||||||
|
import {MailService} from './mail.service';
|
||||||
|
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||||
|
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 {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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'stapps-mail-detail',
|
||||||
|
templateUrl: 'mail-detail.html',
|
||||||
|
styleUrl: 'mail-detail.scss',
|
||||||
|
animations: [materialFade],
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
IonicModule,
|
||||||
|
DataModule,
|
||||||
|
IonIconModule,
|
||||||
|
UtilModule,
|
||||||
|
FormatPurePipeModule,
|
||||||
|
ParseIsoPipeModule,
|
||||||
|
RouterModule,
|
||||||
|
ShadowHtmlDirective,
|
||||||
|
TranslateModule,
|
||||||
|
TitleCasePipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MailDetailComponent {
|
||||||
|
readonly activatedRoute = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
readonly mailService = inject(MailService);
|
||||||
|
|
||||||
|
readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
parameters = this.activatedRoute.paramMap.pipe(
|
||||||
|
map(parameters => ({
|
||||||
|
mailbox: parameters.get('mailbox')!,
|
||||||
|
id: parameters.get('id')!,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
mail = this.parameters.pipe(mergeMap(({mailbox, id}) => this.mailService.getEmail(mailbox, id)));
|
||||||
|
|
||||||
|
collapse = signal(false);
|
||||||
|
|
||||||
|
todo() {
|
||||||
|
alert('TODO');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
frontend/app/src/app/modules/mail/mail-detail.html
Normal file
93
frontend/app/src/app/modules/mail/mail-detail.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar color="primary" mode="ios">
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title
|
||||||
|
[style.opacity]="(collapse() ? '1' : '0') + '!important'"
|
||||||
|
[style.translate]="collapse() ? '0' : '0 10px'"
|
||||||
|
>
|
||||||
|
@if (mail | async; as mail) {
|
||||||
|
{{ mail.subject?.value }}
|
||||||
|
} @else {
|
||||||
|
<ion-skeleton-text animated style="width: 100px; height: 20px"></ion-skeleton-text>
|
||||||
|
}
|
||||||
|
</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="todo()">
|
||||||
|
<ion-icon slot="icon-only" name="delete"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button (click)="todo()">
|
||||||
|
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content parallax [scrollEvents]="true" (ionScroll)="collapse.set($any($event).detail.scrollTop > 50)">
|
||||||
|
@if (mail | async; as mail) {
|
||||||
|
<h1 @materialFade>{{ mail.subject?.value }}</h1>
|
||||||
|
<aside @materialFade>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</strong>
|
||||||
|
<div class="to">
|
||||||
|
to
|
||||||
|
@for (to of mail.to; track to) {
|
||||||
|
<span>{{ to.value.name || to.value.address }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (mail.cc) {
|
||||||
|
<div class="cc">
|
||||||
|
cc
|
||||||
|
@for (cc of mail.cc; track cc) {
|
||||||
|
<span>{{ cc.value.name || cc.value.address }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
@if (mail.html) {
|
||||||
|
<main @materialFade>
|
||||||
|
<div class="html" [shadowHTML]="mail.html.value"></div>
|
||||||
|
</main>
|
||||||
|
} @else if (mail.text) {
|
||||||
|
<main @materialFade>
|
||||||
|
<pre>{{ mail.text.value }}</pre>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
<ion-list>
|
||||||
|
@for (attachment of mail.attachments; track attachment) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
93
frontend/app/src/app/modules/mail/mail-detail.scss
Normal file
93
frontend/app/src/app/modules/mail/mail-detail.scss
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@import '../../../theme/util/mixins';
|
||||||
|
|
||||||
|
ion-item {
|
||||||
|
margin-block-end: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-title {
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
translate 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--ion-color-primary-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: var(--spacing-xs) var(--spacing-md);
|
||||||
|
color: var(--ion-color-primary-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.to {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
> span:has(+ span)::after {
|
||||||
|
content: ',';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.from {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
@include border-radius-in-parallax(var(--border-radius-default));
|
||||||
|
|
||||||
|
margin: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--ion-item-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.html {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: inherit;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
// css hack to make the element stick to the bottom of the scroll container even
|
||||||
|
// when the content is not filling it
|
||||||
|
position: sticky;
|
||||||
|
top: 100vh;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin: var(--spacing-lg);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding-inline-start: var(--spacing-md);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
margin: var(--spacing-md) 0;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
|
||||||
|
border: 1px solid var(--ion-border-color);
|
||||||
|
border-radius: var(--border-radius-default);
|
||||||
|
}
|
||||||
10
frontend/app/src/app/modules/mail/mail-login.component.ts
Normal file
10
frontend/app/src/app/modules/mail/mail-login.component.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'stapps-mail-login',
|
||||||
|
templateUrl: 'mail-login.html',
|
||||||
|
styleUrl: 'mail-login.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class MailLoginComponent {}
|
||||||
0
frontend/app/src/app/modules/mail/mail-login.html
Normal file
0
frontend/app/src/app/modules/mail/mail-login.html
Normal file
0
frontend/app/src/app/modules/mail/mail-login.scss
Normal file
0
frontend/app/src/app/modules/mail/mail-login.scss
Normal file
45
frontend/app/src/app/modules/mail/mail-page.component.ts
Normal file
45
frontend/app/src/app/modules/mail/mail-page.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||||
|
import {MailService} from './mail.service';
|
||||||
|
import {AsyncPipe} from '@angular/common';
|
||||||
|
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, IsTodayPipeModule} from 'ngx-date-fns';
|
||||||
|
import {ActivatedRoute, RouterModule} from '@angular/router';
|
||||||
|
import {combineLatest, map, mergeMap} from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'stapps-mail-page',
|
||||||
|
templateUrl: 'mail-page.html',
|
||||||
|
styleUrl: 'mail-page.scss',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
IonicModule,
|
||||||
|
DataModule,
|
||||||
|
IonIconModule,
|
||||||
|
UtilModule,
|
||||||
|
FormatPurePipeModule,
|
||||||
|
IsTodayPipeModule,
|
||||||
|
RouterModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MailPageComponent {
|
||||||
|
readonly activatedRoute = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
readonly mailService = inject(MailService);
|
||||||
|
|
||||||
|
mailbox = this.activatedRoute.paramMap.pipe(map(parameters => parameters.get('mailbox')!));
|
||||||
|
|
||||||
|
mails = this.mailbox.pipe(
|
||||||
|
mergeMap(mailbox =>
|
||||||
|
this.mailService
|
||||||
|
.listEmails(mailbox)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(emails => combineLatest(emails.map(email => this.mailService.getEmail(mailbox, email)))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/app/src/app/modules/mail/mail-page.html
Normal file
43
frontend/app/src/app/modules/mail/mail-page.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<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-list>
|
||||||
|
} @else {
|
||||||
|
<div>Loading...</div>
|
||||||
|
}
|
||||||
|
</ion-content>
|
||||||
33
frontend/app/src/app/modules/mail/mail-page.scss
Normal file
33
frontend/app/src/app/modules/mail/mail-page.scss
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
29
frontend/app/src/app/modules/mail/mail-storage.provider.ts
Normal file
29
frontend/app/src/app/modules/mail/mail-storage.provider.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {StorageProvider} from '../storage/storage.provider';
|
||||||
|
import {Email} from 'postal-mime';
|
||||||
|
|
||||||
|
@Injectable({providedIn: 'root'})
|
||||||
|
export class MailStorageProvider {
|
||||||
|
constructor(readonly storageProvider: StorageProvider) {}
|
||||||
|
|
||||||
|
private storageKey(...path: string[]): string {
|
||||||
|
return ['mail', ...path.map(encodeURIComponent)].join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(mailbox: string, id: string, email: Email): Promise<void> {
|
||||||
|
await this.storageProvider.put(this.storageKey(mailbox, id), email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(mailbox: string, id: string): Promise<void> {
|
||||||
|
await this.storageProvider.delete(this.storageKey(mailbox, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/app/src/app/modules/mail/mail.module.ts
Normal file
48
frontend/app/src/app/modules/mail/mail.module.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2024 StApps
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the Free
|
||||||
|
* Software Foundation, version 3.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
*
|
||||||
|
* 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 {NgModule, inject} from '@angular/core';
|
||||||
|
import {MailService} from './mail.service';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{
|
||||||
|
path: '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', {})], {}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ':mailbox',
|
||||||
|
loadComponent: () => import('./mail-page.component').then(m => m.MailPageComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':mailbox/:id',
|
||||||
|
loadComponent: () => import('./mail-detail.component').then(m => m.MailDetailComponent),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
206
frontend/app/src/app/modules/mail/mail.service.ts
Normal file
206
frontend/app/src/app/modules/mail/mail.service.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import {HttpClient} from '@angular/common/http';
|
||||||
|
import {Injectable} from '@angular/core';
|
||||||
|
import {Observable, map, tap, mergeMap, of, forkJoin, catchError} from 'rxjs';
|
||||||
|
import PostalMime from 'postal-mime';
|
||||||
|
import {ContentInfo, SignedData} from 'pkijs';
|
||||||
|
import {RawEmail, Email, SignedValue, RawEmailBodyStructure, Signature} from './schema';
|
||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
|
||||||
|
private request<T>(options: {
|
||||||
|
method?: string;
|
||||||
|
path?: string[];
|
||||||
|
options?: Record<string, string>;
|
||||||
|
responseType?: 'json' | 'arraybuffer';
|
||||||
|
}): Observable<T> {
|
||||||
|
return this.httpClient.request<T>(
|
||||||
|
options.method ?? 'GET',
|
||||||
|
`http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
|
||||||
|
options.options
|
||||||
|
? `?${Object.entries(options.options).map(item => item.map(encodeURIComponent).join('='))}`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
responseType: options.responseType as 'json',
|
||||||
|
headers: {authorization: `Basic ${btoa('test:123')}`},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedIn() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
listMailboxes(): Observable<string[]> {
|
||||||
|
return this.request<string[]>({});
|
||||||
|
}
|
||||||
|
|
||||||
|
private listRawEmails(mailbox: string, since?: Date): Observable<string[]> {
|
||||||
|
return this.request<unknown>({
|
||||||
|
path: [mailbox],
|
||||||
|
options: since !== undefined ? {since: since.valueOf().toString()} : undefined,
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(it => z.array(z.string()).parseAsync(it)),
|
||||||
|
tap(console.log),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRawEmail(mailbox: string, id: string): Observable<RawEmail> {
|
||||||
|
return this.request<unknown>({
|
||||||
|
path: [mailbox, id],
|
||||||
|
options: {raw: 'true'},
|
||||||
|
}).pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPart(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
|
||||||
|
return this.request<ArrayBuffer>({path: [mailbox, id, part], responseType: 'arraybuffer'});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRawPart(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
|
||||||
|
return this.request({
|
||||||
|
path: [mailbox, id, part],
|
||||||
|
options: {raw: 'true'},
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRawEmail(mailbox: string, email: RawEmail): Observable<Email> {
|
||||||
|
console.log(email);
|
||||||
|
|
||||||
|
if (
|
||||||
|
email.bodyStructure.type === 'application/x-pkcs7-mime' ||
|
||||||
|
email.bodyStructure.type === 'application/pkcs7-mime'
|
||||||
|
) {
|
||||||
|
return this.getRawPart(mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
|
||||||
|
mergeMap(async buffer => {
|
||||||
|
const info = ContentInfo.fromBER(buffer);
|
||||||
|
const signedData = new SignedData({schema: info.content});
|
||||||
|
const valid = await signedData
|
||||||
|
.verify({signer: 0, data: signedData.encapContentInfo.eContent?.valueBeforeDecodeView})
|
||||||
|
.catch(() => false);
|
||||||
|
const content = new TextDecoder().decode(
|
||||||
|
signedData.encapContentInfo.eContent?.valueBeforeDecodeView,
|
||||||
|
);
|
||||||
|
const signedEmail = await PostalMime.parse(content);
|
||||||
|
|
||||||
|
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
|
||||||
|
: {
|
||||||
|
value,
|
||||||
|
signature: {
|
||||||
|
type: 'pkcs7',
|
||||||
|
valid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Email = {
|
||||||
|
id: email.seq,
|
||||||
|
subject: signedEmail.subject ? signed(signedEmail.subject) : value(email.envelope.subject),
|
||||||
|
flags: new Set<string>(), //TODO
|
||||||
|
from: signed({
|
||||||
|
name: signedEmail.from.name || undefined,
|
||||||
|
address: signedEmail.from.address || undefined,
|
||||||
|
}),
|
||||||
|
to: signedEmail.to?.map(({name, address}) =>
|
||||||
|
signed({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
date: signedEmail.date ? signed(new Date(signedEmail.date)) : value(email.envelope.date),
|
||||||
|
html: signedEmail.html ? signed(signedEmail.html) : undefined,
|
||||||
|
text: signedEmail.text ? signed(signedEmail.text) : undefined,
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const traverse = (
|
||||||
|
item: RawEmailBodyStructure,
|
||||||
|
result: Pick<Email, 'attachments' | 'text' | 'html'>,
|
||||||
|
signature?: Signature,
|
||||||
|
): Observable<Pick<Email, 'attachments' | 'text' | 'html'>> => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
|
||||||
|
if (item.type === 'multipart/signed' && item.parameters?.protocol === 'application/pkcs7-signature') {
|
||||||
|
return forkJoin({
|
||||||
|
data: this.getPart(mailbox, email.seq, item.childNodes![0].part!),
|
||||||
|
signature: this.getRawPart(mailbox, email.seq, item.childNodes![1].part!),
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(({data, signature}) => {
|
||||||
|
const info = ContentInfo.fromBER(signature);
|
||||||
|
const signedData = new SignedData({schema: info.content});
|
||||||
|
return signedData.verify({signer: 0, data});
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.log(error);
|
||||||
|
return of(false);
|
||||||
|
}),
|
||||||
|
mergeMap(valid => traverse(item.childNodes![0], result, {type: 'pkcs7', valid})),
|
||||||
|
);
|
||||||
|
} else if (item.type.startsWith('multipart/')) {
|
||||||
|
return forkJoin(item.childNodes!.map(child => traverse(child, result, signature))).pipe(
|
||||||
|
map(children => children[0]),
|
||||||
|
);
|
||||||
|
} else if (item.type === 'text/plain') {
|
||||||
|
return this.getRawPart(mailbox, email.seq, item.part ?? 'TEXT').pipe(
|
||||||
|
map(text => {
|
||||||
|
result.html = {value: new TextDecoder().decode(text), signature};
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (item.type === 'text/html') {
|
||||||
|
return this.getRawPart(mailbox, email.seq, item.part ?? 'TEXT').pipe(
|
||||||
|
map(html => {
|
||||||
|
result.html = {value: new TextDecoder().decode(html), signature};
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (item.part === undefined) {
|
||||||
|
return of(result);
|
||||||
|
} else {
|
||||||
|
result.attachments.push({value: {part: item.part, size: item.size ?? Number.NaN, filename: ''}});
|
||||||
|
return of(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return traverse(email.bodyStructure, {attachments: []}).pipe(
|
||||||
|
map(partial => ({
|
||||||
|
...partial,
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
tap(console.log),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmail(mailbox: string, id: string): Observable<Email> {
|
||||||
|
return this.getRawEmail(mailbox, id).pipe(
|
||||||
|
mergeMap(it => this.resolveRawEmail(mailbox, it)),
|
||||||
|
tap(console.log),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEmails(mailbox: string): Observable<string[]> {
|
||||||
|
return this.listRawEmails(mailbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/app/src/app/modules/mail/schema.ts
Normal file
96
frontend/app/src/app/modules/mail/schema.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
|
export const RawEmailAddress = z.object({
|
||||||
|
name: z.optional(z.string()),
|
||||||
|
address: z.optional(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawEmailAddress = z.infer<typeof RawEmailAddress>;
|
||||||
|
|
||||||
|
export const RawEmailEnvelope = z.object({
|
||||||
|
date: z.coerce.date(),
|
||||||
|
subject: z.string(),
|
||||||
|
messageId: z.string(),
|
||||||
|
inReplyTo: z.optional(z.string()),
|
||||||
|
from: z.array(RawEmailAddress),
|
||||||
|
sender: z.array(RawEmailAddress),
|
||||||
|
replyTo: z.array(RawEmailAddress),
|
||||||
|
to: z.array(RawEmailAddress),
|
||||||
|
cc: z.optional(z.array(RawEmailAddress)),
|
||||||
|
bcc: z.optional(z.array(RawEmailAddress)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawEmailEnvelope = z.infer<typeof RawEmailEnvelope>;
|
||||||
|
|
||||||
|
const RawEmailBodyStructureBase = z.object({
|
||||||
|
part: z.optional(z.string()),
|
||||||
|
type: z.string(),
|
||||||
|
parameters: z.optional(z.record(z.string(), z.string())),
|
||||||
|
encoding: z.optional(z.enum(['7bit', '8bit', 'binary', 'base64', 'quoted-printable'])),
|
||||||
|
size: z.optional(z.number()),
|
||||||
|
envelope: z.optional(RawEmailEnvelope),
|
||||||
|
disposition: z.optional(z.string()),
|
||||||
|
dispositionParameters: z.optional(z.record(z.string(), z.string())),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawEmailBodyStructure = z.infer<typeof RawEmailBodyStructureBase> & {
|
||||||
|
childNodes?: RawEmailBodyStructure[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RawEmailBodyStructure: z.ZodType<RawEmailBodyStructure> = RawEmailBodyStructureBase.extend({
|
||||||
|
childNodes: z.optional(z.lazy(() => z.array(RawEmailBodyStructure))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RawEmail = z.object({
|
||||||
|
bodyStructure: RawEmailBodyStructure,
|
||||||
|
labels: z.array(z.string()).transform(it => new Set(it)),
|
||||||
|
flags: z.array(z.string()).transform(it => new Set(it)),
|
||||||
|
envelope: RawEmailEnvelope,
|
||||||
|
seq: z.coerce.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawEmail = z.infer<typeof RawEmail>;
|
||||||
|
|
||||||
|
export interface Signature {
|
||||||
|
type: 'pkcs7';
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedValue<T> {
|
||||||
|
value: T;
|
||||||
|
signature?: Signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailAddress {
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailAttachmentBase {
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailAttachmentRemote extends EmailAttachmentBase {
|
||||||
|
part: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailAttachmentLocal extends EmailAttachmentBase {
|
||||||
|
content: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailAttachment = EmailAttachmentRemote | EmailAttachmentLocal;
|
||||||
|
|
||||||
|
export interface Email {
|
||||||
|
id: string;
|
||||||
|
flags: Set<string>;
|
||||||
|
subject?: SignedValue<string>;
|
||||||
|
date: SignedValue<Date>;
|
||||||
|
from: SignedValue<EmailAddress>;
|
||||||
|
to?: SignedValue<EmailAddress>[];
|
||||||
|
cc?: SignedValue<EmailAddress>[];
|
||||||
|
bcc?: SignedValue<EmailAddress>[];
|
||||||
|
html?: SignedValue<string>;
|
||||||
|
text?: SignedValue<string>;
|
||||||
|
attachments: SignedValue<EmailAttachment>[];
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@
|
|||||||
}
|
}
|
||||||
<ion-label>{{ 'name' | translateSimple: link }}</ion-label>
|
<ion-label>{{ 'name' | translateSimple: link }}</ion-label>
|
||||||
</div>
|
</div>
|
||||||
|
@if (link.beta) {
|
||||||
|
<ion-note>
|
||||||
|
<ion-badge color="warning">{{ 'beta' | translate }}</ion-badge>
|
||||||
|
</ion-note>
|
||||||
|
}
|
||||||
</ion-item>
|
</ion-item>
|
||||||
}
|
}
|
||||||
</simple-swiper>
|
</simple-swiper>
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ ion-item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ion-note {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
simple-swiper {
|
simple-swiper {
|
||||||
--swiper-slide-width: #{$width};
|
--swiper-slide-width: #{$width};
|
||||||
|
|
||||||
|
|||||||
25
frontend/app/src/app/util/data-size.pipe.ts
Normal file
25
frontend/app/src/app/util/data-size.pipe.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import {Pipe, PipeTransform} from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a data size in bytes to a human readable string
|
||||||
|
*/
|
||||||
|
export function formatDataSize(value: number, precision = 2): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(precision)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'dataSize',
|
||||||
|
pure: true,
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class DataSizePipe implements PipeTransform {
|
||||||
|
transform(value: number, precision = 2): string {
|
||||||
|
return formatDataSize(value, precision);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/app/src/app/util/shadow-html.directive.ts
Normal file
18
frontend/app/src/app/util/shadow-html.directive.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {Directive, ElementRef, Host, Input} from '@angular/core';
|
||||||
|
import {sanitize} from 'dompurify';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[shadowHTML]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class ShadowHtmlDirective {
|
||||||
|
@Input({required: true})
|
||||||
|
set shadowHTML(content: string) {
|
||||||
|
this.shadowRoot.innerHTML = '';
|
||||||
|
this.shadowRoot.append(sanitize(content, {RETURN_DOM_FRAGMENT: true, USE_PROFILES: {html: true}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
shadowRoot = (this.elementRef.nativeElement as HTMLElement).attachShadow({mode: 'open'});
|
||||||
|
|
||||||
|
constructor(@Host() readonly elementRef: ElementRef) {}
|
||||||
|
}
|
||||||
7
frontend/app/src/app/util/skeleton-data.directive.ts
Normal file
7
frontend/app/src/app/util/skeleton-data.directive.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {Directive} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: 'ng-template[skeletonData]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class SkeletonDataDirective {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {Directive} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: 'ng-template[skeletonPlaceholder]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class SkeletonPlaceholderDirective {}
|
||||||
22
frontend/app/src/app/util/skeleton.component.ts
Normal file
22
frontend/app/src/app/util/skeleton.component.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {ChangeDetectionStrategy, Component, ContentChild, Input, TemplateRef} from '@angular/core';
|
||||||
|
import {Observable} from 'rxjs';
|
||||||
|
import {SkeletonDataDirective} from './skeleton-data.directive';
|
||||||
|
import {SkeletonPlaceholderDirective} from './skeleton-placeholder.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'stapps-skeleton',
|
||||||
|
templateUrl: 'skeleton.html',
|
||||||
|
styleUrl: 'skeleton.scss',
|
||||||
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SkeletonComponent<T> {
|
||||||
|
@ContentChild(SkeletonDataDirective) dataTemplate: TemplateRef<SkeletonDataDirective>;
|
||||||
|
|
||||||
|
@ContentChild(SkeletonPlaceholderDirective) placeholderTemplate: TemplateRef<SkeletonPlaceholderDirective>;
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set data(value: Observable<T> | Promise<T>) {}
|
||||||
|
|
||||||
|
static ngTemplateContextGuard<
|
||||||
|
}
|
||||||
0
frontend/app/src/app/util/skeleton.html
Normal file
0
frontend/app/src/app/util/skeleton.html
Normal file
0
frontend/app/src/app/util/skeleton.scss
vendored
Normal file
0
frontend/app/src/app/util/skeleton.scss
vendored
Normal file
@@ -8,6 +8,7 @@
|
|||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
"share": "Teilen",
|
"share": "Teilen",
|
||||||
"timeSuffix": "Uhr",
|
"timeSuffix": "Uhr",
|
||||||
|
"beta": "Beta",
|
||||||
"ratings": {
|
"ratings": {
|
||||||
"thank_you": "Vielen Dank für die Bewertung!"
|
"thank_you": "Vielen Dank für die Bewertung!"
|
||||||
},
|
},
|
||||||
@@ -386,6 +387,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mail": {
|
||||||
|
"SIGNATURE_VALID": "Signatur gültig",
|
||||||
|
"SIGNATURE_INVALID": "Signatur ungültig",
|
||||||
|
"SIGNATURE_UNSUPPORTED": "Signatur nicht unterstützt",
|
||||||
|
"ID": "ID",
|
||||||
|
"FROM": "von",
|
||||||
|
"SENDER": "Absender",
|
||||||
|
"TO": "an",
|
||||||
|
"DATE": "Datum"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"context": {
|
"context": {
|
||||||
"title": "Kontext Menü",
|
"title": "Kontext Menü",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"back": "back",
|
"back": "back",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
|
"beta": "beta",
|
||||||
"timeSuffix": "",
|
"timeSuffix": "",
|
||||||
"ratings": {
|
"ratings": {
|
||||||
"thank_you": "Thank you for your feedback!"
|
"thank_you": "Thank you for your feedback!"
|
||||||
@@ -386,6 +387,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mail": {
|
||||||
|
"SIGNATURE_VALID": "signature valid",
|
||||||
|
"SIGNATURE_INVALID": "signature invalid",
|
||||||
|
"SIGNATURE_UNSUPPORTED": "signature unsupported",
|
||||||
|
"ID": "ID",
|
||||||
|
"FROM": "from",
|
||||||
|
"SENDER": "sender",
|
||||||
|
"TO": "to",
|
||||||
|
"DATE": "date"
|
||||||
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"context": {
|
"context": {
|
||||||
"title": "context menu",
|
"title": "context menu",
|
||||||
|
|||||||
Binary file not shown.
@@ -49,6 +49,7 @@ export interface SCSectionLink extends SCThing {
|
|||||||
link: string[];
|
link: string[];
|
||||||
needsAuth?: true;
|
needsAuth?: true;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
beta?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SCSection extends SCThing {
|
export interface SCSection extends SCThing {
|
||||||
@@ -150,6 +151,18 @@ export const profilePageSections: SCSection[] = [
|
|||||||
},
|
},
|
||||||
...SCSectionLinkConstantValues,
|
...SCSectionLinkConstantValues,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Mail',
|
||||||
|
icon: SCIcon.mail,
|
||||||
|
link: ['/mail'],
|
||||||
|
beta: true,
|
||||||
|
translations: {
|
||||||
|
de: {
|
||||||
|
name: 'Email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...SCSectionLinkConstantValues,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
translations: {
|
translations: {
|
||||||
de: {
|
de: {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<meta name="msapplication-tap-highlight" content="no" />
|
<meta name="msapplication-tap-highlight" content="no" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
|
<link rel="icon" type="image/png" href="./assets/icon/favicon.png" />
|
||||||
|
|
||||||
<!-- add to homescreen for ios -->
|
<!-- add to homescreen for ios -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|||||||
809
pnpm-lock.yaml
generated
809
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user