mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-02-12 20:02:58 +00:00
feat: mail
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"@openstapps/core-tools": "workspace:*",
|
"@openstapps/core-tools": "workspace:*",
|
||||||
"@openstapps/logger": "workspace:*",
|
"@openstapps/logger": "workspace:*",
|
||||||
"commander": "10.0.0",
|
"commander": "10.0.0",
|
||||||
|
"cors": "2.8.5",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"express": "4.18.2",
|
"express": "4.18.2",
|
||||||
"imapflow": "1.0.162",
|
"imapflow": "1.0.162",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"@openstapps/eslint-config": "workspace:*",
|
"@openstapps/eslint-config": "workspace:*",
|
||||||
"@openstapps/prettier-config": "workspace:*",
|
"@openstapps/prettier-config": "workspace:*",
|
||||||
"@openstapps/tsconfig": "workspace:*",
|
"@openstapps/tsconfig": "workspace:*",
|
||||||
|
"@types/cors": "2.8.13",
|
||||||
"@types/express": "4.17.17",
|
"@types/express": "4.17.17",
|
||||||
"@types/imapflow": "1.0.18",
|
"@types/imapflow": "1.0.18",
|
||||||
"@types/mailparser": "3.4.4",
|
"@types/mailparser": "3.4.4",
|
||||||
|
|||||||
@@ -1,36 +1,74 @@
|
|||||||
import {config} from 'dotenv';
|
import {config} from 'dotenv';
|
||||||
import {ImapFlow} from 'imapflow';
|
import {ImapFlow} from 'imapflow';
|
||||||
|
import {Logger} from '@openstapps/logger';
|
||||||
|
import {createHash} from 'node:crypto';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
config({path: '.env.local'});
|
config({path: '.env.local'});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 4000;
|
const port = process.env.PORT || 4000;
|
||||||
|
|
||||||
|
const maxClientAge = 10_000; // 10 seconds
|
||||||
|
|
||||||
|
const clients = new Map<string, {destroyRef: NodeJS.Timeout; client: Promise<ImapFlow>}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function destroyClient(clientUid: string) {
|
||||||
|
const client = clients.get(clientUid);
|
||||||
|
if (!client) return;
|
||||||
|
clients.delete(clientUid);
|
||||||
|
clearTimeout(client.destroyRef);
|
||||||
|
try {
|
||||||
|
await client.client.then(it => it.logout());
|
||||||
|
} catch (error) {
|
||||||
|
await Logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
app.use(async (request, response, next) => {
|
app.use(async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
const [user, pass] = Buffer.from(request.headers['authorization']!.replace(/^Basic /, ''), 'base64')
|
const authorization = request.headers['authorization'];
|
||||||
.toString('utf8')
|
if (!authorization) {
|
||||||
.split(':');
|
response.status(401).send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const client = new ImapFlow({
|
const clientUid = createHash('sha256').update(authorization).digest('hex');
|
||||||
host: 'imap.server.uni-frankfurt.de',
|
|
||||||
port: 993,
|
|
||||||
secure: true,
|
|
||||||
emitLogs: false,
|
|
||||||
auth: {user, pass},
|
|
||||||
});
|
|
||||||
response.locals.client = client;
|
|
||||||
|
|
||||||
await client.connect();
|
let client = clients.get(clientUid);
|
||||||
response.on('finish', async () => {
|
if (client === undefined) {
|
||||||
await client.logout();
|
const [user, pass] = Buffer.from(authorization.replace(/^Basic /, ''), 'base64')
|
||||||
client.close();
|
.toString('utf8')
|
||||||
});
|
.split(':');
|
||||||
|
const imapClient = new ImapFlow({
|
||||||
|
host: 'imap.server.uni-frankfurt.de',
|
||||||
|
port: 993,
|
||||||
|
secure: true,
|
||||||
|
emitLogs: false,
|
||||||
|
auth: {user, pass},
|
||||||
|
});
|
||||||
|
client = {
|
||||||
|
destroyRef: undefined as unknown as NodeJS.Timeout,
|
||||||
|
client: imapClient.connect().then(() => imapClient),
|
||||||
|
};
|
||||||
|
clients.set(clientUid, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(client.destroyRef);
|
||||||
|
client.destroyRef = setTimeout(() => destroyClient(clientUid), maxClientAge);
|
||||||
|
|
||||||
|
response.locals.client = await client.client;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch (error) {
|
||||||
response.status(401).send();
|
await Logger.error(error);
|
||||||
|
response.status(500).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,52 +79,67 @@ app.get('/', async (_request, response) => {
|
|||||||
|
|
||||||
app.get('/:mailbox', async (request, response) => {
|
app.get('/:mailbox', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
await response.locals.client.mailboxOpen(request.params.mailbox);
|
|
||||||
const since = Number(request.query.since) || undefined;
|
|
||||||
const preData = await response.locals.client.status(request.params.mailbox, {messages: true});
|
const preData = await response.locals.client.status(request.params.mailbox, {messages: true});
|
||||||
if (preData.messages === 0) {
|
response.json({messages: preData.messages});
|
||||||
response.json([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.toString());
|
|
||||||
}
|
|
||||||
response.json(messages);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
await Logger.error(error);
|
||||||
response.status(404).send();
|
response.status(404).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/:mailbox/:id', async (request, response) => {
|
app.get('/:mailbox/:id', async (request, response) => {
|
||||||
try {
|
try {
|
||||||
await response.locals.client.mailboxOpen(request.params.mailbox);
|
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: true});
|
||||||
const message = await response.locals.client.fetchOne(request.params.id, {
|
const message = await response.locals.client.fetchOne(request.params.id, {
|
||||||
envelope: true,
|
envelope: true,
|
||||||
labels: true,
|
labels: true,
|
||||||
flags: true,
|
flags: true,
|
||||||
bodyStructure: true,
|
bodyStructure: request.query.partial ? false : true,
|
||||||
});
|
});
|
||||||
response.json({
|
response.json({
|
||||||
bodyStructure: message.bodyStructure,
|
bodyStructure: request.query.partial ? undefined : message.bodyStructure,
|
||||||
labels: [...(message.labels ?? [])],
|
labels: [...(message.labels ?? [])],
|
||||||
flags: [...(message.flags ?? [])],
|
flags: [...(message.flags ?? [])],
|
||||||
envelope: message.envelope,
|
envelope: message.envelope,
|
||||||
seq: message.seq,
|
seq: message.seq,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
await Logger.error(error);
|
||||||
|
response.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
function parseFlags(query: Record<string, unknown>): string[] {
|
||||||
|
const rawFlags = query['flags'] ?? [];
|
||||||
|
const flagArray = Array.isArray(rawFlags) ? rawFlags : [rawFlags];
|
||||||
|
return flagArray.filter(it => typeof it === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/:mailbox/:id', async (request, response) => {
|
||||||
|
try {
|
||||||
|
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: false});
|
||||||
|
response.json(await response.locals.client.messageFlagsAdd(request.params.id, parseFlags(request.query)));
|
||||||
|
} catch (error) {
|
||||||
|
await Logger.error(error);
|
||||||
|
response.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/:mailbox/:id', async (request, response) => {
|
||||||
|
try {
|
||||||
|
await response.locals.client.mailboxOpen(request.params.mailbox, {readOnly: false});
|
||||||
|
if ('flags' in request.query) {
|
||||||
|
response.json(
|
||||||
|
await response.locals.client.messageFlagsRemove(request.params.id, parseFlags(request.query)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response.json(await response.locals.client.messageDelete(request.params.id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await Logger.error(error);
|
||||||
response.status(404).send();
|
response.status(404).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -113,11 +166,11 @@ app.get('/:mailbox/:id/:part', async (request, response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
await Logger.error(error);
|
||||||
response.status(404).send();
|
response.status(404).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server listening on port ${port}`);
|
Logger.info(`Server listening on port ${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import {HttpClient} from '@angular/common/http';
|
import {HttpClient} from '@angular/common/http';
|
||||||
import {Injectable, inject} from '@angular/core';
|
import {Injectable, inject} from '@angular/core';
|
||||||
import {Observable, map, catchError, tap, mergeMap, forkJoin, of} from 'rxjs';
|
import {Observable, map, catchError, tap, mergeMap, forkJoin, of} from 'rxjs';
|
||||||
import {Email, MailboxTreeRoot, RawEmail, RawEmailBodyStructure, Signature, SignedValue} from './schema';
|
import {
|
||||||
|
Email,
|
||||||
|
EmailWithoutBody,
|
||||||
|
MailboxTreeRoot,
|
||||||
|
RawEmail,
|
||||||
|
RawEmailBodyStructure,
|
||||||
|
Signature,
|
||||||
|
SignedValue,
|
||||||
|
} from './schema';
|
||||||
import {ContentInfo, SignedData} from 'pkijs';
|
import {ContentInfo, SignedData} from 'pkijs';
|
||||||
import PostalMime from 'postal-mime';
|
import PostalMime from 'postal-mime';
|
||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
@@ -22,15 +30,21 @@ export class MailAdapterService {
|
|||||||
request<T>(options: {
|
request<T>(options: {
|
||||||
method?: string;
|
method?: string;
|
||||||
path?: string[];
|
path?: string[];
|
||||||
options?: Record<string, string>;
|
options?: Record<string, string | string[]>;
|
||||||
responseType?: 'json' | 'arraybuffer';
|
responseType?: 'json' | 'arraybuffer';
|
||||||
credentials?: string;
|
credentials?: string;
|
||||||
}): Observable<T> {
|
}): Observable<T> {
|
||||||
return this.httpClient.request<T>(
|
return this.httpClient.request<T>(
|
||||||
options.method ?? 'GET',
|
options.method ?? 'GET',
|
||||||
`https://cumulet.rz.uni-frankfurt.de/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
|
`http://localhost:4000/${options.path?.map(encodeURIComponent).join('/') ?? ''}${
|
||||||
options.options
|
options.options
|
||||||
? `?${Object.entries(options.options).map(item => item.map(encodeURIComponent).join('='))}`
|
? `?${Object.entries(options.options)
|
||||||
|
.flatMap(([key, values]) =>
|
||||||
|
(Array.isArray(values) ? values : [values]).map(
|
||||||
|
value => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('&')}`
|
||||||
: ''
|
: ''
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
@@ -62,22 +76,27 @@ export class MailAdapterService {
|
|||||||
return this.request<string[]>({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it)));
|
return this.request<string[]>({credentials}).pipe(mergeMap(it => MailboxTreeRoot.parseAsync(it)));
|
||||||
}
|
}
|
||||||
|
|
||||||
listRawEmails(credentials: string, mailbox: string, since?: string): Observable<string[]> {
|
countEmails(credentials: string, mailbox: string, since?: string): Observable<number> {
|
||||||
return this.request<unknown>({
|
return this.request<unknown>({
|
||||||
credentials,
|
credentials,
|
||||||
path: [mailbox],
|
path: [mailbox],
|
||||||
options: since === undefined ? undefined : {since},
|
options: since === undefined ? undefined : {since},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
mergeMap(it => z.array(z.string()).parseAsync(it)),
|
mergeMap(it => z.object({messages: z.number()}).parseAsync(it)),
|
||||||
tap(console.log),
|
map(it => it.messages),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRawEmail(credentials: string, mailbox: string, id: string): Observable<RawEmail> {
|
private getRawEmail(
|
||||||
|
credentials: string,
|
||||||
|
mailbox: string,
|
||||||
|
id: string,
|
||||||
|
partial: boolean,
|
||||||
|
): Observable<RawEmail> {
|
||||||
return this.request<unknown>({
|
return this.request<unknown>({
|
||||||
credentials,
|
credentials,
|
||||||
path: [mailbox, id],
|
path: [mailbox, id],
|
||||||
options: {raw: 'true'},
|
options: partial ? {raw: 'true', partial: 'true'} : {raw: 'true'},
|
||||||
}).pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
}).pipe(mergeMap(it => RawEmail.parseAsync(it)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +104,7 @@ export class MailAdapterService {
|
|||||||
return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'});
|
return this.request<ArrayBuffer>({path: [mailbox, id, part], credentials, responseType: 'arraybuffer'});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRawPart(
|
getRawPart(credentials: string, mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
|
||||||
credentials: string,
|
|
||||||
mailbox: string,
|
|
||||||
id: string,
|
|
||||||
part: string,
|
|
||||||
): Observable<ArrayBuffer> {
|
|
||||||
return this.request({
|
return this.request({
|
||||||
path: [mailbox, id, part],
|
path: [mailbox, id, part],
|
||||||
options: {raw: 'true'},
|
options: {raw: 'true'},
|
||||||
@@ -99,12 +113,14 @@ export class MailAdapterService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveRawEmail(credentials: string, mailbox: string, email: RawEmail): Observable<Email> {
|
private resolveRawEmail(
|
||||||
console.log(email);
|
credentials: string,
|
||||||
|
mailbox: string,
|
||||||
|
email: RawEmail,
|
||||||
|
): Observable<Email | EmailWithoutBody> {
|
||||||
if (
|
if (
|
||||||
email.bodyStructure.type === 'application/x-pkcs7-mime' ||
|
email.bodyStructure?.type === 'application/x-pkcs7-mime' ||
|
||||||
email.bodyStructure.type === 'application/pkcs7-mime'
|
email.bodyStructure?.type === 'application/pkcs7-mime'
|
||||||
) {
|
) {
|
||||||
return this.getRawPart(credentials, mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
|
return this.getRawPart(credentials, mailbox, email.seq, email.bodyStructure.part ?? 'TEXT').pipe(
|
||||||
mergeMap(async buffer => {
|
mergeMap(async buffer => {
|
||||||
@@ -190,7 +206,7 @@ export class MailAdapterService {
|
|||||||
} else if (item.type === 'text/plain') {
|
} else if (item.type === 'text/plain') {
|
||||||
return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe(
|
return this.getPart(credentials, mailbox, email.seq, item.part ?? 'TEXT').pipe(
|
||||||
map(text => {
|
map(text => {
|
||||||
result.html = {value: new TextDecoder().decode(text), signature};
|
result.text = {value: new TextDecoder().decode(text), signature};
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -204,34 +220,113 @@ export class MailAdapterService {
|
|||||||
} else if (item.part === undefined) {
|
} else if (item.part === undefined) {
|
||||||
return of(result);
|
return of(result);
|
||||||
} else {
|
} else {
|
||||||
result.attachments.push({value: {part: item.part, size: item.size ?? Number.NaN, filename: ''}});
|
result.attachments.push({
|
||||||
|
value: {
|
||||||
|
part: item.part,
|
||||||
|
size: item.size ?? Number.NaN,
|
||||||
|
filename: item.parameters?.['name'] ?? item.part,
|
||||||
|
},
|
||||||
|
});
|
||||||
return of(result);
|
return of(result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return traverse(email.bodyStructure, {attachments: []}).pipe(
|
const emailWithoutBody: Omit<EmailWithoutBody, 'partial'> = {
|
||||||
map(
|
id: email.seq,
|
||||||
partial =>
|
mailbox,
|
||||||
({
|
flags: new Set<string>(email.flags),
|
||||||
...partial,
|
subject: value(email.envelope.subject),
|
||||||
id: email.seq,
|
from: email.envelope.from[0]
|
||||||
mailbox,
|
? value({
|
||||||
flags: new Set<string>(email.flags),
|
name: email.envelope.from[0].name,
|
||||||
subject: value(email.envelope.subject),
|
address: email.envelope.from[0].address,
|
||||||
from: value({
|
})
|
||||||
name: email.envelope.from[0]?.name || undefined,
|
: value({
|
||||||
address: email.envelope.from[0]?.address || undefined,
|
name: email.envelope.sender[0].name,
|
||||||
}),
|
address: email.envelope.sender[0].address,
|
||||||
date: value(email.envelope.date),
|
}),
|
||||||
}) satisfies Email,
|
to: email.envelope.to?.map(({name, address}) =>
|
||||||
|
value({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
tap(console.log),
|
cc: email.envelope.cc?.map(({name, address}) =>
|
||||||
);
|
value({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
bcc: email.envelope.bcc?.map(({name, address}) =>
|
||||||
|
value({
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
date: value(email.envelope.date),
|
||||||
|
};
|
||||||
|
|
||||||
|
return email.bodyStructure === undefined
|
||||||
|
? of({...emailWithoutBody, partial: true})
|
||||||
|
: traverse(email.bodyStructure, {attachments: []}).pipe(
|
||||||
|
map(
|
||||||
|
partial =>
|
||||||
|
({
|
||||||
|
...partial,
|
||||||
|
...emailWithoutBody,
|
||||||
|
}) satisfies Email,
|
||||||
|
),
|
||||||
|
tap(console.log),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmail(credentials: string, mailbox: string, id: string): Observable<Email> {
|
getEmail(
|
||||||
return this.getRawEmail(credentials, mailbox, id).pipe(
|
credentials: string,
|
||||||
|
mailbox: string,
|
||||||
|
id: string,
|
||||||
|
partial: boolean,
|
||||||
|
): Observable<Email | EmailWithoutBody> {
|
||||||
|
return this.getRawEmail(credentials, mailbox, id, partial).pipe(
|
||||||
mergeMap(it => this.resolveRawEmail(credentials, mailbox, it)),
|
mergeMap(it => this.resolveRawEmail(credentials, mailbox, it)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addFlags(credentials: string, mailbox: string, id: string, flags: string | string[]): Observable<boolean> {
|
||||||
|
return Array.isArray(flags) && flags.length === 0
|
||||||
|
? of(true)
|
||||||
|
: this.request<boolean>({
|
||||||
|
credentials,
|
||||||
|
method: 'POST',
|
||||||
|
path: [mailbox, id],
|
||||||
|
options: {
|
||||||
|
flags: flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFlags(
|
||||||
|
credentials: string,
|
||||||
|
mailbox: string,
|
||||||
|
id: string,
|
||||||
|
flags: string | string[],
|
||||||
|
): Observable<boolean> {
|
||||||
|
return Array.isArray(flags) && flags.length === 0
|
||||||
|
? of(true)
|
||||||
|
: this.request<boolean>({
|
||||||
|
credentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: [mailbox, id],
|
||||||
|
options: {
|
||||||
|
flags: flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmail(credentials: string, mailbox: string, id: string): Observable<boolean> {
|
||||||
|
return this.request<boolean>({
|
||||||
|
credentials,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: [mailbox, id],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
|
import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core';
|
||||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||||
import {IonicModule} from '@ionic/angular';
|
import {IonRouterOutlet, IonicModule} from '@ionic/angular';
|
||||||
import {DataModule} from '../data/data.module';
|
import {DataModule} from '../data/data.module';
|
||||||
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
||||||
import {UtilModule} from 'src/app/util/util.module';
|
import {UtilModule} from 'src/app/util/util.module';
|
||||||
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
|
import {FormatPurePipeModule, ParseIsoPipeModule} from 'ngx-date-fns';
|
||||||
import {ActivatedRoute, RouterModule} from '@angular/router';
|
import {ActivatedRoute, RouterModule} from '@angular/router';
|
||||||
import {map, mergeMap} from 'rxjs';
|
import {firstValueFrom, map, mergeMap, take} from 'rxjs';
|
||||||
import {DomSanitizer} from '@angular/platform-browser';
|
import {DomSanitizer} from '@angular/platform-browser';
|
||||||
import {materialFade} from 'src/app/animation/material-motion';
|
import {materialFade} from 'src/app/animation/material-motion';
|
||||||
import {TranslateModule} from '@ngx-translate/core';
|
import {TranslateModule} from '@ngx-translate/core';
|
||||||
import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
|
import {ShadowHtmlDirective} from 'src/app/util/shadow-html.directive';
|
||||||
import {MailStorageProvider} from './mail-storage.provider';
|
import {MailStorageProvider} from './mail-storage.provider';
|
||||||
|
import {DataSizePipe} from '../../util/data-size.pipe';
|
||||||
|
import {EmailAttachment, EmailAttachmentRemote} from './schema';
|
||||||
|
import {MailService} from './mail.service';
|
||||||
|
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'stapps-mail-detail',
|
selector: 'stapps-mail-detail',
|
||||||
@@ -32,6 +36,7 @@ import {MailStorageProvider} from './mail-storage.provider';
|
|||||||
ShadowHtmlDirective,
|
ShadowHtmlDirective,
|
||||||
TranslateModule,
|
TranslateModule,
|
||||||
TitleCasePipe,
|
TitleCasePipe,
|
||||||
|
DataSizePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MailDetailComponent {
|
export class MailDetailComponent {
|
||||||
@@ -39,8 +44,12 @@ export class MailDetailComponent {
|
|||||||
|
|
||||||
readonly mailStorage = inject(MailStorageProvider);
|
readonly mailStorage = inject(MailStorageProvider);
|
||||||
|
|
||||||
|
readonly mailService = inject(MailService);
|
||||||
|
|
||||||
readonly sanitizer = inject(DomSanitizer);
|
readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
readonly router = inject(IonRouterOutlet);
|
||||||
|
|
||||||
parameters = this.activatedRoute.paramMap.pipe(
|
parameters = this.activatedRoute.paramMap.pipe(
|
||||||
map(parameters => ({
|
map(parameters => ({
|
||||||
mailbox: parameters.get('mailbox')!,
|
mailbox: parameters.get('mailbox')!,
|
||||||
@@ -52,7 +61,42 @@ export class MailDetailComponent {
|
|||||||
|
|
||||||
collapse = signal(false);
|
collapse = signal(false);
|
||||||
|
|
||||||
todo() {
|
constructor() {
|
||||||
alert('TODO');
|
this.mail.pipe(take(1), takeUntilDestroyed()).subscribe(mail => {
|
||||||
|
this.mailStorage.addFlags(mail, ['\\Seen']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markUnread() {
|
||||||
|
await this.mailStorage.removeFlags(await firstValueFrom(this.mail), ['\\Seen']);
|
||||||
|
await this.router.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
this.mailStorage.deleteEmail(await firstValueFrom(this.mail));
|
||||||
|
await this.router.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAttachment(attachment: EmailAttachment) {
|
||||||
|
const data = await firstValueFrom(
|
||||||
|
this.mail.pipe(
|
||||||
|
take(1),
|
||||||
|
mergeMap(mail =>
|
||||||
|
this.mailService.downloadAttachment(
|
||||||
|
mail.mailbox,
|
||||||
|
mail.id,
|
||||||
|
(attachment as EmailAttachmentRemote).part,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(new Blob([data], {}));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = attachment.filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
}
|
}
|
||||||
</ion-title>
|
</ion-title>
|
||||||
<ion-buttons slot="end">
|
<ion-buttons slot="end">
|
||||||
<ion-button (click)="todo()">
|
<ion-button (click)="delete()">
|
||||||
<ion-icon slot="icon-only" name="delete"></ion-icon>
|
<ion-icon slot="icon-only" name="delete"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button (click)="todo()">
|
<ion-button (click)="markUnread()">
|
||||||
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
|
<ion-icon slot="icon-only" name="mark_email_unread"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
@@ -28,16 +28,21 @@
|
|||||||
@if (mail | async; as mail) {
|
@if (mail | async; as mail) {
|
||||||
<h1 @materialFade>{{ mail.subject?.value }}</h1>
|
<h1 @materialFade>{{ mail.subject?.value }}</h1>
|
||||||
<aside @materialFade>
|
<aside @materialFade>
|
||||||
<strong class="from">
|
<div class="from">
|
||||||
{{ mail.from.value.name || mail.from.value.address }}
|
@if (mail.from.value.name) {
|
||||||
|
<strong>
|
||||||
|
{{ mail.from.value.name }}
|
||||||
|
</strong>
|
||||||
|
}
|
||||||
|
{{ mail.from.value.address }}
|
||||||
@if (mail.from.signature?.valid === true) {
|
@if (mail.from.signature?.valid === true) {
|
||||||
<ion-icon name="verified" [fill]="true" @materialFade></ion-icon>
|
<ion-icon name="verified" [fill]="true" @materialFade></ion-icon>
|
||||||
} @else if (mail.from.signature?.valid === false) {
|
} @else if (mail.from.signature?.valid === false) {
|
||||||
<ion-icon name="gpp_bad" color="danger" [fill]="true" @materialFade></ion-icon>
|
<ion-icon name="gpp_bad" color="danger" [fill]="true" @materialFade></ion-icon>
|
||||||
}
|
}
|
||||||
</strong>
|
</div>
|
||||||
<div class="to">
|
<div class="to">
|
||||||
to
|
<strong>to</strong>
|
||||||
@for (to of mail.to; track to) {
|
@for (to of mail.to; track to) {
|
||||||
<span>{{ to.value.name || to.value.address }}</span>
|
<span>{{ to.value.name || to.value.address }}</span>
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (mail.date) {
|
||||||
|
<time [dateTime]="mail.date">{{ mail.date.value | dfnsFormatPure: 'PPp' }}</time>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
@if (mail.html) {
|
@if (mail.html) {
|
||||||
<main @materialFade>
|
<main @materialFade>
|
||||||
@@ -60,34 +69,13 @@
|
|||||||
<pre>{{ mail.text.value }}</pre>
|
<pre>{{ mail.text.value }}</pre>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
<ion-list>
|
<div class="attachments">
|
||||||
@for (attachment of mail.attachments; track attachment) {
|
@for (attachment of mail.attachments; track attachment) {
|
||||||
<ion-card>
|
<ion-button class="attachment" fill="outline" (click)="downloadAttachment(attachment.value)">
|
||||||
<ion-card-header>
|
<ion-icon slot="start" name="download"></ion-icon>
|
||||||
<ion-card-title>{{ attachment.value.filename }}</ion-card-title>
|
<ion-label>{{ attachment.value.filename }} ({{ attachment.value.size | dataSize }})</ion-label>
|
||||||
<ion-card-subtitle>{{ attachment.value.size }}</ion-card-subtitle>
|
</ion-button>
|
||||||
</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>
|
</div>
|
||||||
<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>
|
</ion-content>
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ main {
|
|||||||
background: var(--ion-item-background);
|
background: var(--ion-item-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ion-list {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-card {
|
||||||
|
ion-card-header {
|
||||||
|
padding-block: var(--spacing-sm);
|
||||||
|
padding-inline: var(--spacing-sm);
|
||||||
|
|
||||||
|
ion-card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-card-content {
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.html {
|
.html {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -80,16 +100,17 @@ footer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment {
|
.attachments {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
gap: var(--spacing-md);
|
||||||
|
|
||||||
margin: var(--spacing-md) 0;
|
margin-block-end: var(--spacing-xl);
|
||||||
padding: var(--spacing-md);
|
margin-inline: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
border: 1px solid var(--ion-border-color);
|
.attachment[fill='outline']::part(native) {
|
||||||
border-radius: var(--border-radius-default);
|
border: 1px solid currentcolor;
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-content::part(background) {
|
ion-content::part(background) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
firstValueFrom,
|
firstValueFrom,
|
||||||
Subject,
|
Subject,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {Email, EmailMeta, MailboxTreeRoot} from './schema';
|
import {Email, EmailMeta, EmailWithoutBody, MailboxTreeRoot} from './schema';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import {MailAdapterService} from './mail-adapter.service';
|
import {MailAdapterService} from './mail-adapter.service';
|
||||||
import {StorageProvider} from '../storage/storage.provider';
|
import {StorageProvider} from '../storage/storage.provider';
|
||||||
@@ -147,7 +147,9 @@ export class MailStorageProvider {
|
|||||||
const request = index.getAll(IDBKeyRange.only(mailbox));
|
const request = index.getAll(IDBKeyRange.only(mailbox));
|
||||||
|
|
||||||
return merge(
|
return merge(
|
||||||
fromEvent(request, 'success').pipe(map(() => request.result as Array<EmailMeta | Email>)),
|
fromEvent(request, 'success').pipe(
|
||||||
|
map(() => request.result as Array<EmailMeta | EmailWithoutBody | Email>),
|
||||||
|
),
|
||||||
fromEvent(request, 'error').pipe(
|
fromEvent(request, 'error').pipe(
|
||||||
map(event => {
|
map(event => {
|
||||||
throw (event.target as IDBRequest).error;
|
throw (event.target as IDBRequest).error;
|
||||||
@@ -156,7 +158,7 @@ export class MailStorageProvider {
|
|||||||
).pipe(take(1));
|
).pipe(take(1));
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
map<Array<Email | EmailMeta>, EmailMeta[]>(emails =>
|
map<Array<Email | EmailWithoutBody | EmailMeta>, EmailMeta[]>(emails =>
|
||||||
emails
|
emails
|
||||||
.map(email => ({id: email.id, mailbox: email.mailbox, incomplete: true}) satisfies EmailMeta)
|
.map(email => ({id: email.id, mailbox: email.mailbox, incomplete: true}) satisfies EmailMeta)
|
||||||
.sort((a, b) => Number(b.id) - Number(a.id)),
|
.sort((a, b) => Number(b.id) - Number(a.id)),
|
||||||
@@ -165,7 +167,10 @@ export class MailStorageProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmail(mailbox: string, id: string): Observable<Email> {
|
getEmail(mailbox: string, id: string): Observable<Email>;
|
||||||
|
getEmail(mailbox: string, id: string, partial: false): Observable<Email>;
|
||||||
|
getEmail(mailbox: string, id: string, partial: boolean): Observable<Email | EmailWithoutBody>;
|
||||||
|
getEmail(mailbox: string, id: string, partial = false): Observable<Email | EmailWithoutBody> {
|
||||||
return this.emailChanged.pipe(
|
return this.emailChanged.pipe(
|
||||||
filter(it => it.has(JSON.stringify([id, mailbox]))),
|
filter(it => it.has(JSON.stringify([id, mailbox]))),
|
||||||
startWith(undefined),
|
startWith(undefined),
|
||||||
@@ -188,16 +193,14 @@ export class MailStorageProvider {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
mergeMap(email =>
|
mergeMap(email =>
|
||||||
'incomplete' in email
|
'incomplete' in email || (!partial && 'partial' in email)
|
||||||
? this.credentials.pipe(
|
? this.credentials.pipe(
|
||||||
filter(it => it !== undefined),
|
filter(it => it !== undefined),
|
||||||
take(1),
|
take(1),
|
||||||
mergeMap(credentials =>
|
mergeMap(credentials =>
|
||||||
this.mailAdapter.getEmail(credentials!, mailbox, id).pipe(
|
this.mailAdapter.getEmail(credentials!, mailbox, id, partial).pipe(
|
||||||
mergeMap(async email => {
|
mergeMap(async email => {
|
||||||
console.log('fetiching');
|
|
||||||
await this.setEmail(email, true);
|
await this.setEmail(email, true);
|
||||||
console.log('done');
|
|
||||||
return email;
|
return email;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -209,7 +212,10 @@ export class MailStorageProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setEmail(email: Email | EmailMeta | Array<Email | EmailMeta>, quiet = false): Promise<void> {
|
async setEmail(
|
||||||
|
email: Email | EmailMeta | EmailWithoutBody | Array<Email | EmailMeta | EmailWithoutBody>,
|
||||||
|
quiet = false,
|
||||||
|
): Promise<void> {
|
||||||
const database = await firstValueFrom(this.database);
|
const database = await firstValueFrom(this.database);
|
||||||
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
|
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
|
||||||
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
|
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
|
||||||
@@ -240,4 +246,64 @@ export class MailStorageProvider {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addFlags(email: Email | EmailWithoutBody, flags: string[]): Promise<boolean> {
|
||||||
|
let hasChanged = false;
|
||||||
|
for (const flag of flags) {
|
||||||
|
if (!email.flags.has(flag)) {
|
||||||
|
hasChanged = true;
|
||||||
|
email.flags.add(flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
const credentials = await firstValueFrom(this.credentials);
|
||||||
|
await this.setEmail(email);
|
||||||
|
return firstValueFrom(this.mailAdapter.addFlags(credentials!, email.mailbox, email.id, flags));
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFlags(email: Email | EmailWithoutBody, flags: string[]): Promise<boolean> {
|
||||||
|
let hasChanged = false;
|
||||||
|
for (const flag of flags) {
|
||||||
|
if (email.flags.has(flag)) {
|
||||||
|
hasChanged = true;
|
||||||
|
email.flags.delete(flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
const credentials = await firstValueFrom(this.credentials);
|
||||||
|
await this.setEmail(email);
|
||||||
|
return firstValueFrom(this.mailAdapter.removeFlags(credentials!, email.mailbox, email.id, flags));
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(email: EmailMeta | Email | EmailWithoutBody): Promise<boolean> {
|
||||||
|
const credentials = await firstValueFrom(this.credentials);
|
||||||
|
const result = await firstValueFrom(this.mailAdapter.deleteEmail(credentials!, email.mailbox, email.id));
|
||||||
|
const database = await firstValueFrom(this.database);
|
||||||
|
const transaction = database.transaction([MailStorageProvider.EMAIL_STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(MailStorageProvider.EMAIL_STORE_NAME);
|
||||||
|
store.delete([email.id, email.mailbox]);
|
||||||
|
return firstValueFrom(
|
||||||
|
merge(
|
||||||
|
fromEvent(transaction, 'complete').pipe(
|
||||||
|
map(() => {
|
||||||
|
this.mailboxContentChanged.next(new Set([email.mailbox]));
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
fromEvent(transaction, 'error').pipe(
|
||||||
|
map(event => {
|
||||||
|
throw (event.target as IDBRequest).error;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import {NgModule, inject} from '@angular/core';
|
|||||||
import {MailService} from './mail.service';
|
import {MailService} from './mail.service';
|
||||||
import {map, take} from 'rxjs';
|
import {map, take} from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function mailLoginGuard() {
|
function mailLoginGuard() {
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
return inject(MailService).isLoggedIn.pipe(
|
return inject(MailService).isLoggedIn.pipe(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Pipe, PipeTransform, inject} from '@angular/core';
|
import {Pipe, PipeTransform, inject} from '@angular/core';
|
||||||
import {Observable} from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import {MailStorageProvider} from './mail-storage.provider';
|
import {MailStorageProvider} from './mail-storage.provider';
|
||||||
import {Email} from './schema';
|
import {Email, EmailWithoutBody} from './schema';
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'mail',
|
name: 'mail',
|
||||||
@@ -12,6 +12,19 @@ export class MailPipe implements PipeTransform {
|
|||||||
mailStorage = inject(MailStorageProvider);
|
mailStorage = inject(MailStorageProvider);
|
||||||
|
|
||||||
transform(value: {mailbox: string; id: string}): Observable<Email> {
|
transform(value: {mailbox: string; id: string}): Observable<Email> {
|
||||||
return this.mailStorage.getEmail(value.mailbox, value.id);
|
return this.mailStorage.getEmail(value.mailbox, value.id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'partialMail',
|
||||||
|
pure: true,
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class PartialMailPipe implements PipeTransform {
|
||||||
|
mailStorage = inject(MailStorageProvider);
|
||||||
|
|
||||||
|
transform(value: {mailbox: string; id: string}): Observable<EmailWithoutBody | Email> {
|
||||||
|
return this.mailStorage.getEmail(value.mailbox, value.id, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
of,
|
of,
|
||||||
catchError,
|
catchError,
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
take,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {MailStorageProvider} from './mail-storage.provider';
|
import {MailStorageProvider} from './mail-storage.provider';
|
||||||
import {MailAdapterService} from './mail-adapter.service';
|
import {MailAdapterService} from './mail-adapter.service';
|
||||||
@@ -48,10 +49,15 @@ export class MailService {
|
|||||||
mergeMap(([credentials, mailboxes]) =>
|
mergeMap(([credentials, mailboxes]) =>
|
||||||
from(mailboxes.folders).pipe(
|
from(mailboxes.folders).pipe(
|
||||||
mergeMap(async mailbox => {
|
mergeMap(async mailbox => {
|
||||||
return this.mailAdapter.listRawEmails(credentials!, mailbox.path).pipe(
|
return this.mailAdapter.countEmails(credentials!, mailbox.path).pipe(
|
||||||
map<string[], EmailMeta[]>(emails =>
|
map<number, EmailMeta[]>(count =>
|
||||||
emails.map(it => ({id: it, mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta),
|
Array.from(
|
||||||
|
{length: count},
|
||||||
|
(_, i) =>
|
||||||
|
({id: (i + 1).toString(), mailbox: mailbox.path, incomplete: true}) satisfies EmailMeta,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
tap(emails => console.log(emails)),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return of();
|
return of();
|
||||||
@@ -81,4 +87,12 @@ export class MailService {
|
|||||||
this.mailStorage.setCredentials(undefined);
|
this.mailStorage.setCredentials(undefined);
|
||||||
this.mailStorage.setMailboxes(undefined);
|
this.mailStorage.setMailboxes(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadAttachment(mailbox: string, id: string, part: string): Observable<ArrayBuffer> {
|
||||||
|
return this.mailStorage.credentials.pipe(
|
||||||
|
filter(it => it !== undefined),
|
||||||
|
take(1),
|
||||||
|
mergeMap(credentials => this.mailAdapter.getRawPart(credentials!, mailbox, id, part)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {IonIconModule} from 'src/app/util/ion-icon/ion-icon.module';
|
|||||||
import {TranslateModule} from '@ngx-translate/core';
|
import {TranslateModule} from '@ngx-translate/core';
|
||||||
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
import {AsyncPipe, TitleCasePipe} from '@angular/common';
|
||||||
import {LazyComponent} from 'src/app/util/lazy.component';
|
import {LazyComponent} from 'src/app/util/lazy.component';
|
||||||
import {MailPipe} from './mail.pipe';
|
import {PartialMailPipe} from './mail.pipe';
|
||||||
import {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
|
import {FormatPurePipeModule, IsTodayPipeModule} from 'ngx-date-fns';
|
||||||
import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
|
import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ import {LazyLoadPipe} from 'src/app/util/lazy-load.pipe';
|
|||||||
TranslateModule,
|
TranslateModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
LazyComponent,
|
LazyComponent,
|
||||||
MailPipe,
|
PartialMailPipe,
|
||||||
IsTodayPipeModule,
|
IsTodayPipeModule,
|
||||||
FormatPurePipeModule,
|
FormatPurePipeModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<ion-list>
|
<ion-list>
|
||||||
@for (mail of mails; track mail.id) {
|
@for (mail of mails; track mail.id) {
|
||||||
<ion-item #item [routerLink]="['/mail', mail.mailbox, mail.id]">
|
<ion-item #item [routerLink]="['/mail', mail.mailbox, mail.id]">
|
||||||
@if (mail | mail | lazyLoad: item | async; as mail) {
|
@if (mail | partialMail | lazyLoad: item | async; as mail) {
|
||||||
<div slot="start" class="avatar">
|
<div slot="start" class="avatar">
|
||||||
@if (mail.from; as from) {
|
@if (mail.from; as from) {
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ p > ion-skeleton-text {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-item.unread h2 {
|
h2.unread {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const RawEmailBodyStructure: z.ZodType<RawEmailBodyStructure> = RawEmailB
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const RawEmail = z.object({
|
export const RawEmail = z.object({
|
||||||
bodyStructure: RawEmailBodyStructure,
|
bodyStructure: z.optional(RawEmailBodyStructure),
|
||||||
labels: z.array(z.string()).transform(it => new Set(it)),
|
labels: z.array(z.string()).transform(it => new Set(it)),
|
||||||
flags: z.array(z.string()).transform(it => new Set(it)),
|
flags: z.array(z.string()).transform(it => new Set(it)),
|
||||||
envelope: RawEmailEnvelope,
|
envelope: RawEmailEnvelope,
|
||||||
@@ -125,4 +125,6 @@ export interface Email {
|
|||||||
attachments: SignedValue<EmailAttachment>[];
|
attachments: SignedValue<EmailAttachment>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmailWithoutBody = Omit<Email, 'html' | 'text' | 'attachments'> & {partial: true};
|
||||||
|
|
||||||
export type EmailMeta = Pick<Email, 'id' | 'mailbox'> & {incomplete: true};
|
export type EmailMeta = Pick<Email, 'id' | 'mailbox'> & {incomplete: true};
|
||||||
|
|||||||
Binary file not shown.
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -260,6 +260,9 @@ importers:
|
|||||||
commander:
|
commander:
|
||||||
specifier: 10.0.0
|
specifier: 10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
|
cors:
|
||||||
|
specifier: 2.8.5
|
||||||
|
version: 2.8.5
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: 16.4.5
|
specifier: 16.4.5
|
||||||
version: 16.4.5
|
version: 16.4.5
|
||||||
@@ -291,6 +294,9 @@ importers:
|
|||||||
'@openstapps/tsconfig':
|
'@openstapps/tsconfig':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../configuration/tsconfig
|
version: link:../../configuration/tsconfig
|
||||||
|
'@types/cors':
|
||||||
|
specifier: 2.8.13
|
||||||
|
version: 2.8.13
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: 4.17.17
|
specifier: 4.17.17
|
||||||
version: 4.17.17
|
version: 4.17.17
|
||||||
@@ -8066,13 +8072,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
|
resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.15.3
|
'@types/node': 18.15.3
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@types/cors@2.8.17:
|
|
||||||
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 18.15.3
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/d3-scale-chromatic@3.0.3:
|
/@types/d3-scale-chromatic@3.0.3:
|
||||||
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
|
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
|
||||||
@@ -12111,7 +12110,7 @@ packages:
|
|||||||
engines: {node: '>=10.2.0'}
|
engines: {node: '>=10.2.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/cookie': 0.4.1
|
'@types/cookie': 0.4.1
|
||||||
'@types/cors': 2.8.17
|
'@types/cors': 2.8.13
|
||||||
'@types/node': 18.15.3
|
'@types/node': 18.15.3
|
||||||
accepts: 1.3.8
|
accepts: 1.3.8
|
||||||
base64id: 2.0.0
|
base64id: 2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user