mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-19 16:13:06 +00:00
feat: mail
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
|
||||
import {MailService} from './mail.service';
|
||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
@@ -7,7 +7,7 @@ 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 {mergeMap} from 'rxjs';
|
||||
import {map, mergeMap} from 'rxjs';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {materialFade} from 'src/app/animation/material-motion';
|
||||
import {TranslateModule} from '@ngx-translate/core';
|
||||
@@ -35,16 +35,23 @@ import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
|
||||
],
|
||||
})
|
||||
export class MailDetailComponent {
|
||||
mail = this.activatedRoute.params.pipe(mergeMap(({id}) => this.mailService.getEmail(id)));
|
||||
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);
|
||||
|
||||
constructor(
|
||||
readonly mailService: MailService,
|
||||
readonly activatedRoute: ActivatedRoute,
|
||||
readonly sanitizer: DomSanitizer,
|
||||
) {}
|
||||
|
||||
todo() {
|
||||
alert('TODO');
|
||||
}
|
||||
|
||||
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
@@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
|
||||
import {MailService} from './mail.service';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
import {IonicModule} from '@ionic/angular';
|
||||
@@ -6,7 +6,8 @@ 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 {RouterModule} from '@angular/router';
|
||||
import {ActivatedRoute, RouterModule} from '@angular/router';
|
||||
import {combineLatest, map, mergeMap} from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'stapps-mail-page',
|
||||
@@ -26,7 +27,19 @@ import {RouterModule} from '@angular/router';
|
||||
],
|
||||
})
|
||||
export class MailPageComponent {
|
||||
mails = this.mailService.list();
|
||||
readonly activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
constructor(readonly mailService: MailService) {}
|
||||
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)))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,25 +10,28 @@
|
||||
@if (mails | async; as mails) {
|
||||
<ion-list>
|
||||
@for (mail of mails; track mail) {
|
||||
<ion-item [routerLink]="['/mail', mail.seq]" [class.unread]="!mail.flags.has('\\Seen')">
|
||||
<ion-item
|
||||
[routerLink]="['/mail', mailbox | async, mail.id]"
|
||||
[class.unread]="!mail.flags.has('\\Seen')"
|
||||
>
|
||||
<div slot="start" class="avatar">
|
||||
@if (mail.envelope.from[0]; as from) {
|
||||
@if (mail.from; as from) {
|
||||
<div>
|
||||
{{ (from.name || from.address)?.charAt(0)?.toUpperCase() }}
|
||||
{{ (from.value.name || from.value.address)?.charAt(0)?.toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ion-label>
|
||||
@for (from of mail.envelope.from; track from) {
|
||||
<h2>{{ from.name || from.address }}</h2>
|
||||
<h2>{{ mail.from.value.name || mail.from.value.address }}</h2>
|
||||
@if (mail.subject) {
|
||||
<p>{{ mail.subject.value }}</p>
|
||||
}
|
||||
<p>{{ mail.envelope.subject }}</p>
|
||||
</ion-label>
|
||||
<ion-note slot="end">
|
||||
@if (mail.envelope.date | dfnsIsToday) {
|
||||
{{ mail.envelope.date | dfnsFormatPure: 'p' }}
|
||||
@if (mail.date.value | dfnsIsToday) {
|
||||
{{ mail.date.value | dfnsFormatPure: 'p' }}
|
||||
} @else {
|
||||
{{ mail.envelope.date | dfnsFormatPure: 'P' }}
|
||||
{{ mail.date.value | dfnsFormatPure: 'P' }}
|
||||
}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,36 @@
|
||||
* 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} from '@angular/router';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {MailPageComponent} from './mail-page.component';
|
||||
import {MailDetailComponent} from './mail-detail.component';
|
||||
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', component: MailPageComponent},
|
||||
{path: 'mail/:id', component: MailDetailComponent},
|
||||
{
|
||||
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),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,66 +1,86 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Observable, map, tap, mergeMap, of, merge, forkJoin, catchError, mergeScan} from 'rxjs';
|
||||
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,
|
||||
EmailAttachment,
|
||||
EmailAttachmentRemote,
|
||||
Signature,
|
||||
} from './schema';
|
||||
import {RawEmail, Email, SignedValue, RawEmailBodyStructure, Signature} from './schema';
|
||||
import {z} from 'zod';
|
||||
import {compareAsc} from 'date-fns';
|
||||
|
||||
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 listRawEmails(): Observable<RawEmail[]> {
|
||||
return this.httpClient.get('http://localhost:4000/', {responseType: 'json'}).pipe(
|
||||
map<unknown, RawEmail[]>(it => {
|
||||
return z.array(RawEmail).parse(it);
|
||||
}),
|
||||
map(it => it.sort((a, b) => compareAsc(b.envelope.date, a.envelope.date))),
|
||||
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(id: string): Observable<RawEmail> {
|
||||
return this.httpClient
|
||||
.get(`http://localhost:4000/${id}`, {responseType: 'json'})
|
||||
.pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
||||
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 getFullAttachment(id: string | number, attachment: string): Observable<ArrayBuffer> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/raw/${attachment}`, {
|
||||
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 getRawAttachment(id: string | number, attachment = ''): Observable<ArrayBuffer> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/attachment/${attachment}`, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRawEmail(email: RawEmail): Observable<Email> {
|
||||
private resolveRawEmail(mailbox: string, email: RawEmail): Observable<Email> {
|
||||
console.log(email);
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
if (
|
||||
email.bodyStructure.type === 'application/x-pkcs7-mime' ||
|
||||
email.bodyStructure.type === 'application/pkcs7-mime'
|
||||
) {
|
||||
return this.getRawAttachment(email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
|
||||
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});
|
||||
@@ -87,7 +107,9 @@ export class MailService {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -117,8 +139,8 @@ export class MailService {
|
||||
// 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.getFullAttachment(email.seq, item.childNodes![0].part!),
|
||||
signature: this.getRawAttachment(email.seq, item.childNodes![1].part!),
|
||||
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);
|
||||
@@ -136,23 +158,23 @@ export class MailService {
|
||||
map(children => children[0]),
|
||||
);
|
||||
} else if (item.type === 'text/plain') {
|
||||
return this.getRawAttachment(email.seq, item.part ?? 'TEXT').pipe(
|
||||
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.getRawAttachment(email.seq, item.part ?? 'TEXT').pipe(
|
||||
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) {
|
||||
result.attachments.push({value: {part: item.part, size: item.size ?? NaN, filename: ''}});
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
@@ -171,87 +193,14 @@ export class MailService {
|
||||
);
|
||||
}
|
||||
|
||||
getEmail(id: string): Observable<Email> {
|
||||
return this.getRawEmail(id).pipe(
|
||||
mergeMap(it => this.resolveRawEmail(it)),
|
||||
getEmail(mailbox: string, id: string): Observable<Email> {
|
||||
return this.getRawEmail(mailbox, id).pipe(
|
||||
mergeMap(it => this.resolveRawEmail(mailbox, it)),
|
||||
tap(console.log),
|
||||
);
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.listRawEmails().pipe(
|
||||
tap(it => {
|
||||
const email = it[7];
|
||||
console.log(email);
|
||||
}),
|
||||
);
|
||||
listEmails(mailbox: string): Observable<string[]> {
|
||||
return this.listRawEmails(mailbox);
|
||||
}
|
||||
|
||||
/*getREmail(id: string): Observable<SignedEmail> {
|
||||
return this.httpClient.get(`http://localhost:4000/${id}/attachment`, {responseType: 'arraybuffer'}).pipe(
|
||||
map(buffer => new TextDecoder().decode(buffer)),
|
||||
mergeMap(async content => {
|
||||
const email = (await PostalMime.parse(content)) as SignedEmail;
|
||||
email.signature = 'none';
|
||||
|
||||
const signedMail = email.attachments.find(attachment =>
|
||||
/application\/(x-)?pkcs7-mime/.test(attachment.mimeType),
|
||||
);
|
||||
if (signedMail) {
|
||||
const info = ContentInfo.fromBER(signedMail.content);
|
||||
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)) as SignedEmail;
|
||||
signedEmail.signature = valid ? 'valid' : 'invalid';
|
||||
return signedEmail;
|
||||
}
|
||||
|
||||
const signatureIndex = email.attachments.findIndex(attachment =>
|
||||
/application\/(x-)?pkcs7-signature/.test(attachment.mimeType),
|
||||
);
|
||||
if (signatureIndex === -1) {
|
||||
return email;
|
||||
}
|
||||
|
||||
email.signature = 'unsupported';
|
||||
|
||||
const signature = email.attachments.splice(signatureIndex, 1)[0];
|
||||
const info = ContentInfo.fromBER(signature.content);
|
||||
const signedData = new SignedData({schema: info.content});
|
||||
|
||||
const boundary = email.headers
|
||||
.find(header => header.key?.toLowerCase() === 'content-type')
|
||||
?.value?.match(/boundary=["']?([^"'\s;]+)["']?/)?.[1];
|
||||
if (boundary === undefined) {
|
||||
console.warn('No boundary found');
|
||||
return email;
|
||||
}
|
||||
|
||||
const parts = content.split(`\r\n--${boundary}\r\n`);
|
||||
if (parts.length !== 3) {
|
||||
console.warn('Invalid parts', parts);
|
||||
return email;
|
||||
}
|
||||
|
||||
const valid = await signedData
|
||||
.verify({signer: 0, data: new TextEncoder().encode(parts[1])})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
return false;
|
||||
});
|
||||
email.signature = valid ? 'valid' : 'invalid';
|
||||
|
||||
return email;
|
||||
}),
|
||||
tap(console.log),
|
||||
);
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface EmailAttachmentLocal extends EmailAttachmentBase {
|
||||
export type EmailAttachment = EmailAttachmentRemote | EmailAttachmentLocal;
|
||||
|
||||
export interface Email {
|
||||
id: string;
|
||||
flags: Set<string>;
|
||||
subject?: SignedValue<string>;
|
||||
date: SignedValue<Date>;
|
||||
from: SignedValue<EmailAddress>;
|
||||
|
||||
Reference in New Issue
Block a user