mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-17 07:02:51 +00:00
feat: mail
This commit is contained in:
@@ -7,58 +7,65 @@ config({path: '.env.local'});
|
||||
const app = express();
|
||||
const port = process.env.PORT || 4000;
|
||||
|
||||
if (!process.env.IMAP_USER || !process.env.IMAP_PASSWORD) {
|
||||
throw new Error('Provide IMAP user');
|
||||
}
|
||||
app.use(async (request, response, next) => {
|
||||
try {
|
||||
const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64')
|
||||
.toString('utf8')
|
||||
.split(':');
|
||||
|
||||
app.use((_request, response, next) => {
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.server.uni-frankfurt.de',
|
||||
port: 993,
|
||||
secure: true,
|
||||
emitLogs: false,
|
||||
auth: {
|
||||
user: process.env.IMAP_USER!,
|
||||
pass: process.env.IMAP_PASSWORD!,
|
||||
},
|
||||
});
|
||||
response.locals.client = client;
|
||||
next();
|
||||
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 client = response.locals.client as ImapFlow;
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
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 client.fetch('1:*', {
|
||||
envelope: true,
|
||||
labels: true,
|
||||
bodyStructure: true,
|
||||
flags: true,
|
||||
})) {
|
||||
messages.push({
|
||||
bodyStructure: message.bodyStructure,
|
||||
labels: [...(message.labels ?? [])],
|
||||
flags: [...(message.flags ?? [])],
|
||||
envelope: message.envelope,
|
||||
seq: message.seq,
|
||||
});
|
||||
for await (const message of data) {
|
||||
messages.push(message.seq);
|
||||
}
|
||||
response.json(messages);
|
||||
} finally {
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(404).send();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
app.get('/:id', async (request, response) => {
|
||||
const client = response.locals.client as ImapFlow;
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
app.get('/:mailbox/:id', async (request, response) => {
|
||||
try {
|
||||
const message = await client.fetchOne(request.params.id, {
|
||||
await response.locals.client.mailboxOpen(request.params.mailbox);
|
||||
const message = await response.locals.client.fetchOne(request.params.id, {
|
||||
envelope: true,
|
||||
labels: true,
|
||||
flags: true,
|
||||
@@ -71,49 +78,39 @@ app.get('/:id', async (request, response) => {
|
||||
envelope: message.envelope,
|
||||
seq: message.seq,
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
response.status(404).send();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
app.get('/:id/attachment/:attachment?', async (request, response) => {
|
||||
const client = response.locals.client as ImapFlow;
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
app.get('/:mailbox/:id/:part', async (request, response) => {
|
||||
try {
|
||||
const message = await client.download(request.params.id, request.params.attachment);
|
||||
message.content.on('data', chunk => {
|
||||
response.write(chunk);
|
||||
});
|
||||
message.content.on('end', () => {
|
||||
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();
|
||||
});
|
||||
} finally {
|
||||
lock.release();
|
||||
} 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();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
app.get('/:id/raw/:part', async (request, response) => {
|
||||
const client = response.locals.client as ImapFlow;
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const message = await 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();
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on http://localhost:${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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
127
pnpm-lock.yaml
generated
127
pnpm-lock.yaml
generated
@@ -330,6 +330,9 @@ 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)
|
||||
@@ -2720,6 +2723,38 @@ 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:
|
||||
@@ -5640,7 +5675,7 @@ packages:
|
||||
object-assign: 4.1.1
|
||||
open: 8.4.0
|
||||
proxy-middleware: 0.15.0
|
||||
send: 0.18.0
|
||||
send: 0.19.0
|
||||
serve-index: 1.9.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -7068,6 +7103,10 @@ 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
|
||||
@@ -9997,6 +10036,10 @@ 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
|
||||
@@ -10443,6 +10486,11 @@ 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'}
|
||||
@@ -13503,6 +13551,7 @@ packages:
|
||||
|
||||
/glob@7.1.6:
|
||||
resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
@@ -15606,6 +15655,10 @@ 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
|
||||
@@ -15621,6 +15674,10 @@ 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
|
||||
@@ -17354,7 +17411,6 @@ 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==}
|
||||
@@ -19162,6 +19218,27 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/send@0.19.0:
|
||||
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 1.0.2
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.0
|
||||
mime: 1.6.0
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/serialize-javascript@6.0.0:
|
||||
resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==}
|
||||
dependencies:
|
||||
@@ -20330,6 +20407,30 @@ 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'}
|
||||
@@ -21472,6 +21573,11 @@ 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'}
|
||||
@@ -22149,6 +22255,11 @@ 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'}
|
||||
@@ -22245,6 +22356,18 @@ packages:
|
||||
engines: {node: '>=12.20'}
|
||||
dev: true
|
||||
|
||||
/z-schema@5.0.5:
|
||||
resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
lodash.get: 4.4.2
|
||||
lodash.isequal: 4.5.0
|
||||
validator: 13.12.0
|
||||
optionalDependencies:
|
||||
commander: 9.5.0
|
||||
dev: true
|
||||
|
||||
/zepto@1.2.0:
|
||||
resolution: {integrity: sha512-C1x6lfvBICFTQIMgbt3JqMOno3VOtkWat/xEakLTOurskYIHPmzJrzd1e8BnmtdDVJlGuk5D+FxyCA8MPmkIyA==}
|
||||
dev: true
|
||||
|
||||
Reference in New Issue
Block a user