@axium/email 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +157 -0
- package/README.md +40 -0
- package/db.json +83 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/web_hook.d.ts +8 -0
- package/dist/client/web_hook.js +11 -0
- package/dist/common.d.ts +454 -0
- package/dist/common.js +127 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/api.d.ts +1 -0
- package/dist/server/api.js +138 -0
- package/dist/server/cli.d.ts +1 -0
- package/dist/server/cli.js +69 -0
- package/dist/server/db.d.ts +17 -0
- package/dist/server/db.js +2 -0
- package/dist/server/hooks.d.ts +4 -0
- package/dist/server/hooks.js +13 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/send.d.ts +45 -0
- package/dist/server/send.js +221 -0
- package/dist/server/smtp.d.ts +2 -0
- package/dist/server/smtp.js +128 -0
- package/lib/AddressChips.svelte +107 -0
- package/lib/Composer.svelte +245 -0
- package/lib/MessageRow.svelte +160 -0
- package/lib/ThreadMessage.svelte +218 -0
- package/lib/index.ts +4 -0
- package/lib/tsconfig.json +13 -0
- package/locales/en.json +57 -0
- package/package.json +83 -0
- package/routes/email/+layout.svelte +59 -0
- package/routes/email/+layout.ts +17 -0
- package/routes/email/+page.ts +5 -0
- package/routes/email/[folder]/+page.svelte +53 -0
- package/routes/email/[folder]/+page.ts +16 -0
- package/routes/email/[folder]/[id]/+page.svelte +133 -0
- package/routes/email/[folder]/[id]/+page.ts +12 -0
- package/routes/email/context.ts +10 -0
- package/routes/email/email.css +143 -0
- package/routes/tsconfig.json +13 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { checkAuthForUser } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { error, parseBody, parseSearch, withError } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
6
|
+
import * as z from 'zod';
|
|
7
|
+
import { EmailChangeable, EmailInit, EmailListQuery } from '../common.js';
|
|
8
|
+
import './db.js';
|
|
9
|
+
import { makeSnippet, send } from './send.js';
|
|
10
|
+
function withAttachments(eb) {
|
|
11
|
+
return jsonArrayFrom(eb
|
|
12
|
+
.selectFrom('email_attachments')
|
|
13
|
+
.select(['id', 'emailId', 'filename', 'contentType', 'size'])
|
|
14
|
+
.whereRef('email_attachments.emailId', '=', 'emails.id'))
|
|
15
|
+
.$castTo()
|
|
16
|
+
.as('attachments');
|
|
17
|
+
}
|
|
18
|
+
/** Fetch a message and check that the requesting user owns its mailbox */
|
|
19
|
+
async function authEmail(request, id) {
|
|
20
|
+
const email = await database
|
|
21
|
+
.selectFrom('emails')
|
|
22
|
+
.selectAll()
|
|
23
|
+
.select(withAttachments)
|
|
24
|
+
.where('id', '=', id)
|
|
25
|
+
.executeTakeFirstOrThrow()
|
|
26
|
+
.catch(withError('Email does not exist', 404));
|
|
27
|
+
await checkAuthForUser(request, email.userId);
|
|
28
|
+
return email;
|
|
29
|
+
}
|
|
30
|
+
const draftOnlyFields = ['to', 'cc', 'bcc', 'subject', 'text'];
|
|
31
|
+
addRoute({
|
|
32
|
+
path: '/api/users/:id/email',
|
|
33
|
+
params: { id: z.uuid() },
|
|
34
|
+
async GET(request, { id: userId }) {
|
|
35
|
+
await checkAuthForUser(request, userId);
|
|
36
|
+
const query = parseSearch(request, EmailListQuery);
|
|
37
|
+
return await database
|
|
38
|
+
.selectFrom('emails')
|
|
39
|
+
.selectAll()
|
|
40
|
+
.select(withAttachments)
|
|
41
|
+
.where('userId', '=', userId)
|
|
42
|
+
.$if(!!query.starred, qb => qb.where('starred', '=', true).where('folder', 'not in', ['trash', 'spam']))
|
|
43
|
+
.$if(!query.starred, qb => qb.where('folder', '=', query.folder ?? 'inbox'))
|
|
44
|
+
.orderBy('date', 'desc')
|
|
45
|
+
.limit(query.limit)
|
|
46
|
+
.offset(query.offset)
|
|
47
|
+
.execute()
|
|
48
|
+
.catch(withError('Could not get emails'));
|
|
49
|
+
},
|
|
50
|
+
async PUT(request, { id: userId }) {
|
|
51
|
+
const init = await parseBody(request, EmailInit);
|
|
52
|
+
const { user } = await checkAuthForUser(request, userId);
|
|
53
|
+
return await send(user, init);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
addRoute({
|
|
57
|
+
path: '/api/email/:id',
|
|
58
|
+
params: { id: z.uuid() },
|
|
59
|
+
async GET(request, { id }) {
|
|
60
|
+
return await authEmail(request, id);
|
|
61
|
+
},
|
|
62
|
+
async PATCH(request, { id }) {
|
|
63
|
+
const changes = await parseBody(request, EmailChangeable);
|
|
64
|
+
const email = await authEmail(request, id);
|
|
65
|
+
if (email.folder != 'drafts' && draftOnlyFields.some(field => field in changes))
|
|
66
|
+
error(400, 'Only drafts can have their content changed');
|
|
67
|
+
const { to, cc, bcc, ...rest } = changes;
|
|
68
|
+
return Object.assign(await database
|
|
69
|
+
.updateTable('emails')
|
|
70
|
+
.set(rest)
|
|
71
|
+
.$if('to' in changes, qb => qb.set('to', JSON.stringify(to)))
|
|
72
|
+
.$if('cc' in changes, qb => qb.set('cc', JSON.stringify(cc)))
|
|
73
|
+
.$if('bcc' in changes, qb => qb.set('bcc', JSON.stringify(bcc)))
|
|
74
|
+
.$if('text' in changes, qb => qb.set('snippet', makeSnippet(changes.text)))
|
|
75
|
+
.where('id', '=', id)
|
|
76
|
+
.returningAll()
|
|
77
|
+
.executeTakeFirstOrThrow()
|
|
78
|
+
.catch(withError('Could not update email')), { attachments: email.attachments });
|
|
79
|
+
},
|
|
80
|
+
async DELETE(request, { id }) {
|
|
81
|
+
const email = await authEmail(request, id);
|
|
82
|
+
// deleting moves to trash; deleting from trash (or drafts) is permanent
|
|
83
|
+
if (email.folder != 'trash' && email.folder != 'drafts') {
|
|
84
|
+
const row = await database
|
|
85
|
+
.updateTable('emails')
|
|
86
|
+
.set('folder', 'trash')
|
|
87
|
+
.where('id', '=', id)
|
|
88
|
+
.returningAll()
|
|
89
|
+
.executeTakeFirstOrThrow()
|
|
90
|
+
.catch(withError('Could not delete email'));
|
|
91
|
+
return Object.assign(row, { attachments: email.attachments });
|
|
92
|
+
}
|
|
93
|
+
const row = await database
|
|
94
|
+
.deleteFrom('emails')
|
|
95
|
+
.where('id', '=', id)
|
|
96
|
+
.returningAll()
|
|
97
|
+
.executeTakeFirstOrThrow()
|
|
98
|
+
.catch(withError('Could not delete email'));
|
|
99
|
+
return Object.assign(row, { attachments: email.attachments });
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
addRoute({
|
|
103
|
+
path: '/api/email/:id/thread',
|
|
104
|
+
params: { id: z.uuid() },
|
|
105
|
+
async GET(request, { id }) {
|
|
106
|
+
const email = await authEmail(request, id);
|
|
107
|
+
return await database
|
|
108
|
+
.selectFrom('emails')
|
|
109
|
+
.selectAll()
|
|
110
|
+
.select(withAttachments)
|
|
111
|
+
.where('userId', '=', email.userId)
|
|
112
|
+
.where('threadId', '=', email.threadId)
|
|
113
|
+
.orderBy('date', 'asc')
|
|
114
|
+
.execute()
|
|
115
|
+
.catch(withError('Could not get thread'));
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
addRoute({
|
|
119
|
+
path: '/api/email/:id/attachments/:attachmentId',
|
|
120
|
+
params: { id: z.uuid(), attachmentId: z.uuid() },
|
|
121
|
+
async GET(request, { id, attachmentId }) {
|
|
122
|
+
await authEmail(request, id);
|
|
123
|
+
const attachment = await database
|
|
124
|
+
.selectFrom('email_attachments')
|
|
125
|
+
.selectAll()
|
|
126
|
+
.where('id', '=', attachmentId)
|
|
127
|
+
.where('emailId', '=', id)
|
|
128
|
+
.executeTakeFirstOrThrow()
|
|
129
|
+
.catch(withError('Attachment does not exist', 404));
|
|
130
|
+
return new Response(new Uint8Array(attachment.content), {
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': attachment.contentType,
|
|
133
|
+
'Content-Length': attachment.size.toString(),
|
|
134
|
+
'Content-Disposition': `attachment; filename="${attachment.filename.replaceAll('"', '')}"`,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { config, hostname } from '@axium/server/config';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import * as io from 'ioium/node';
|
|
5
|
+
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { styleText } from 'node:util';
|
|
8
|
+
const cli = program.command('email').helpGroup('Plugins:').description('CLI integration for @axium/email');
|
|
9
|
+
/** The DKIM TXT record for a private key */
|
|
10
|
+
function dkimRecord(privatePEM) {
|
|
11
|
+
const publicKey = createPublicKey(createPrivateKey(privatePEM)).export({ type: 'spki', format: 'der' }).toString('base64');
|
|
12
|
+
return `${config.email.dkim.selector}._domainkey.${hostname()}.\tIN\tTXT\t"v=DKIM1; k=rsa; p=${publicKey}"`;
|
|
13
|
+
}
|
|
14
|
+
cli.command('dns')
|
|
15
|
+
.description('Print the DNS records needed to send and receive email')
|
|
16
|
+
.action(() => {
|
|
17
|
+
const domain = hostname();
|
|
18
|
+
const host = new URL(config.origin).hostname;
|
|
19
|
+
const { key_file } = config.email.dkim;
|
|
20
|
+
console.log(styleText('whiteBright', '; Receiving (MX)'));
|
|
21
|
+
console.log(`${domain}.\tIN\tMX\t10 ${host}.`);
|
|
22
|
+
console.log(styleText('whiteBright', '\n; Sending (SPF)'));
|
|
23
|
+
console.log(`${domain}.\tIN\tTXT\t"v=spf1 mx -all"`);
|
|
24
|
+
console.log(styleText('whiteBright', '\n; DKIM'));
|
|
25
|
+
if (key_file && existsSync(key_file)) {
|
|
26
|
+
try {
|
|
27
|
+
console.log(dkimRecord(readFileSync(key_file, 'utf8')));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
io.warn('Could not read the DKIM key: ' + io.errorText(e));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log(styleText('dim', '; Generate a DKIM key with `axium email dkim-keygen` first'));
|
|
35
|
+
}
|
|
36
|
+
console.log(styleText('whiteBright', '\n; DMARC'));
|
|
37
|
+
console.log(`_dmarc.${domain}.\tIN\tTXT\t"v=DMARC1; p=quarantine; rua=mailto:postmaster@${domain}"`);
|
|
38
|
+
});
|
|
39
|
+
cli.command('queue')
|
|
40
|
+
.description('Show the outbound email queue')
|
|
41
|
+
.option('-a, --all', 'Include delivered and failed messages', false)
|
|
42
|
+
.option('--retry', 'Retry failed messages now', false)
|
|
43
|
+
.action(async (opt) => {
|
|
44
|
+
if (opt.retry) {
|
|
45
|
+
const retried = await database
|
|
46
|
+
.updateTable('email_outbound')
|
|
47
|
+
.set({ status: 'pending', attempts: 0, nextAttempt: new Date() })
|
|
48
|
+
.where('status', '=', 'failed')
|
|
49
|
+
.returningAll()
|
|
50
|
+
.execute();
|
|
51
|
+
io.info(`Marked ${retried.length} messages for retry. They will be sent when the server processes the queue.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const jobs = await database
|
|
55
|
+
.selectFrom('email_outbound')
|
|
56
|
+
.innerJoin('emails', 'emails.id', 'email_outbound.emailId')
|
|
57
|
+
.select(['email_outbound.id', 'recipient', 'status', 'attempts', 'nextAttempt', 'lastError', 'emails.subject'])
|
|
58
|
+
.$if(!opt.all, qb => qb.where('status', '=', 'pending'))
|
|
59
|
+
.orderBy('nextAttempt', 'asc')
|
|
60
|
+
.execute();
|
|
61
|
+
if (!jobs.length) {
|
|
62
|
+
console.log(styleText(['italic', 'dim'], 'The queue is empty.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const job of jobs) {
|
|
66
|
+
const color = job.status == 'sent' ? 'green' : job.status == 'failed' ? 'red' : 'yellow';
|
|
67
|
+
console.log(styleText(color, job.status.padEnd(7)), job.recipient, styleText('dim', `(${job.attempts} attempts, next ${job.nextAttempt.toISOString()})`), styleText('yellow', JSON.stringify(job.subject)), job.lastError ? styleText('red', job.lastError) : '');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FromFile as FromSchemaFile } from '@axium/server/db/schema';
|
|
2
|
+
import type { ColumnType, Generated } from 'kysely';
|
|
3
|
+
import type schema from '../../db.json';
|
|
4
|
+
import type { EmailFolder, Mailbox } from '../common.js';
|
|
5
|
+
type _DB = FromSchemaFile<typeof schema>;
|
|
6
|
+
declare module '@axium/server/database' {
|
|
7
|
+
interface Schema extends Omit<_DB, 'emails'> {
|
|
8
|
+
emails: Omit<_DB['emails'], 'from' | 'to' | 'cc' | 'bcc' | 'folder'> & {
|
|
9
|
+
from: ColumnType<Mailbox, string | Mailbox, string | Mailbox>;
|
|
10
|
+
to: ColumnType<Mailbox[], string | Mailbox[], string | Mailbox[]>;
|
|
11
|
+
cc: ColumnType<Mailbox[], string | Mailbox[], string | Mailbox[]>;
|
|
12
|
+
bcc: ColumnType<Mailbox[], string | Mailbox[], string | Mailbox[]>;
|
|
13
|
+
folder: Generated<EmailFolder>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { count } from '@axium/server/database';
|
|
2
|
+
import '../common.js';
|
|
3
|
+
import './api.js';
|
|
4
|
+
import { startOutbound } from './send.js';
|
|
5
|
+
import { startInbound } from './smtp.js';
|
|
6
|
+
export async function statusText() {
|
|
7
|
+
const { emails } = await count('emails');
|
|
8
|
+
return `${emails} emails`;
|
|
9
|
+
}
|
|
10
|
+
export function load() {
|
|
11
|
+
startInbound();
|
|
12
|
+
startOutbound();
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { User } from '@axium/core';
|
|
2
|
+
import type { Email, EmailFolder, EmailInit, Mailbox } from '../common.js';
|
|
3
|
+
import './db.js';
|
|
4
|
+
/** The address of a user on this server */
|
|
5
|
+
export declare function addressFor(user: Pick<User, 'username'>): string | null;
|
|
6
|
+
/** Make a list snippet from a body, excluding quoted replies */
|
|
7
|
+
export declare function makeSnippet(text?: string | null): string | null;
|
|
8
|
+
/** Resolve the user an address belongs to, if it is local to this server */
|
|
9
|
+
export declare function localUser(address: string): Promise<{
|
|
10
|
+
id: string;
|
|
11
|
+
} | null>;
|
|
12
|
+
/** Find the thread a reply belongs to in a user's mailbox */
|
|
13
|
+
export declare function threadFor(userId: string, inReplyTo?: string | null): Promise<string>;
|
|
14
|
+
export interface MessageData {
|
|
15
|
+
messageId: string;
|
|
16
|
+
inReplyTo?: string | null;
|
|
17
|
+
references?: string[];
|
|
18
|
+
from: Mailbox;
|
|
19
|
+
to: Mailbox[];
|
|
20
|
+
cc: Mailbox[];
|
|
21
|
+
bcc: Mailbox[];
|
|
22
|
+
subject: string;
|
|
23
|
+
text?: string | null;
|
|
24
|
+
html?: string | null;
|
|
25
|
+
date: Date;
|
|
26
|
+
size: number;
|
|
27
|
+
}
|
|
28
|
+
export interface IncomingAttachment {
|
|
29
|
+
filename: string;
|
|
30
|
+
contentType: string;
|
|
31
|
+
content: Uint8Array<ArrayBuffer>;
|
|
32
|
+
}
|
|
33
|
+
/** Store a message in a user's mailbox */
|
|
34
|
+
export declare function deliver(userId: string, folder: EmailFolder, data: MessageData, attachments?: IncomingAttachment[]): Promise<Email>;
|
|
35
|
+
/**
|
|
36
|
+
* Send a message composed by a local user: store the sender's copy,
|
|
37
|
+
* deliver to local recipients, and queue delivery to external recipients.
|
|
38
|
+
*/
|
|
39
|
+
export declare function send(sender: User, init: EmailInit): Promise<Email>;
|
|
40
|
+
/** Push a message to its mailbox owner over the socket */
|
|
41
|
+
export declare function notifyReceived(email: Email): void;
|
|
42
|
+
/** Periodically deliver queued outbound messages */
|
|
43
|
+
export declare function startOutbound(): void;
|
|
44
|
+
export declare function stopOutbound(): void;
|
|
45
|
+
export declare function processOutbound(): Promise<void>;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { hostname } from '@axium/server/config';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { sendMail } from '@axium/server/email';
|
|
4
|
+
import { error, withError } from '@axium/server/requests';
|
|
5
|
+
import { io as socketServer } from '@axium/server/socket';
|
|
6
|
+
import * as io from 'ioium/node';
|
|
7
|
+
import { pick } from 'utilium';
|
|
8
|
+
import { splitQuoted } from '../common.js';
|
|
9
|
+
import './db.js';
|
|
10
|
+
/** The address of a user on this server */
|
|
11
|
+
export function addressFor(user) {
|
|
12
|
+
return user.username ? `${user.username}@${hostname()}` : null;
|
|
13
|
+
}
|
|
14
|
+
/** Make a list snippet from a body, excluding quoted replies */
|
|
15
|
+
export function makeSnippet(text) {
|
|
16
|
+
if (!text)
|
|
17
|
+
return null;
|
|
18
|
+
return splitQuoted(text).content.replaceAll(/\s+/g, ' ').trim().slice(0, 160) || null;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve the user an address belongs to, if it is local to this server */
|
|
21
|
+
export async function localUser(address) {
|
|
22
|
+
const [local, domain] = address.toLowerCase().split('@');
|
|
23
|
+
if (domain != hostname())
|
|
24
|
+
return null;
|
|
25
|
+
const user = await database.selectFrom('users').select('id').where('username', '=', local).executeTakeFirst();
|
|
26
|
+
return user ?? null;
|
|
27
|
+
}
|
|
28
|
+
/** Find the thread a reply belongs to in a user's mailbox */
|
|
29
|
+
export async function threadFor(userId, inReplyTo) {
|
|
30
|
+
if (inReplyTo) {
|
|
31
|
+
const ref = await database
|
|
32
|
+
.selectFrom('emails')
|
|
33
|
+
.select('threadId')
|
|
34
|
+
.where('userId', '=', userId)
|
|
35
|
+
.where('messageId', '=', inReplyTo)
|
|
36
|
+
.orderBy('received', 'desc')
|
|
37
|
+
.executeTakeFirst();
|
|
38
|
+
if (ref)
|
|
39
|
+
return ref.threadId;
|
|
40
|
+
}
|
|
41
|
+
return crypto.randomUUID();
|
|
42
|
+
}
|
|
43
|
+
/** Store a message in a user's mailbox */
|
|
44
|
+
export async function deliver(userId, folder, data, attachments = []) {
|
|
45
|
+
const row = await database
|
|
46
|
+
.insertInto('emails')
|
|
47
|
+
.values({
|
|
48
|
+
userId,
|
|
49
|
+
folder,
|
|
50
|
+
...data,
|
|
51
|
+
threadId: await threadFor(userId, data.inReplyTo),
|
|
52
|
+
snippet: makeSnippet(data.text),
|
|
53
|
+
read: folder == 'sent' || folder == 'drafts',
|
|
54
|
+
})
|
|
55
|
+
.returningAll()
|
|
56
|
+
.executeTakeFirstOrThrow();
|
|
57
|
+
const stored = { ...row, attachments: [] };
|
|
58
|
+
for (const attachment of attachments) {
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
60
|
+
const { content: _, ...meta } = await database
|
|
61
|
+
.insertInto('email_attachments')
|
|
62
|
+
.values({
|
|
63
|
+
emailId: row.id,
|
|
64
|
+
filename: attachment.filename,
|
|
65
|
+
contentType: attachment.contentType,
|
|
66
|
+
size: BigInt(attachment.content.byteLength),
|
|
67
|
+
content: attachment.content,
|
|
68
|
+
})
|
|
69
|
+
.returningAll()
|
|
70
|
+
.executeTakeFirstOrThrow();
|
|
71
|
+
stored.attachments.push(meta);
|
|
72
|
+
}
|
|
73
|
+
return stored;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Send a message composed by a local user: store the sender's copy,
|
|
77
|
+
* deliver to local recipients, and queue delivery to external recipients.
|
|
78
|
+
*/
|
|
79
|
+
export async function send(sender, init) {
|
|
80
|
+
const address = addressFor(sender);
|
|
81
|
+
if (!address)
|
|
82
|
+
error(409, 'You need a username to use email');
|
|
83
|
+
const data = {
|
|
84
|
+
messageId: `<${crypto.randomUUID()}@${hostname()}>`,
|
|
85
|
+
...pick(init, 'inReplyTo', 'to', 'cc', 'bcc', 'subject', 'text'),
|
|
86
|
+
references: [],
|
|
87
|
+
from: { name: sender.name, address },
|
|
88
|
+
date: new Date(),
|
|
89
|
+
size: new TextEncoder().encode(init.text ?? '').byteLength,
|
|
90
|
+
};
|
|
91
|
+
if (init.inReplyTo) {
|
|
92
|
+
const ref = await database
|
|
93
|
+
.selectFrom('emails')
|
|
94
|
+
.select('references')
|
|
95
|
+
.where('userId', '=', sender.id)
|
|
96
|
+
.where('messageId', '=', init.inReplyTo)
|
|
97
|
+
.executeTakeFirst();
|
|
98
|
+
data.references = [...(ref?.references ?? []), init.inReplyTo];
|
|
99
|
+
}
|
|
100
|
+
if (init.draftId) {
|
|
101
|
+
await database
|
|
102
|
+
.deleteFrom('emails')
|
|
103
|
+
.where('id', '=', init.draftId)
|
|
104
|
+
.where('userId', '=', sender.id)
|
|
105
|
+
.where('folder', '=', 'drafts')
|
|
106
|
+
.execute()
|
|
107
|
+
.catch(withError('Could not consume draft'));
|
|
108
|
+
}
|
|
109
|
+
if (init.isDraft)
|
|
110
|
+
return await deliver(sender.id, 'drafts', data);
|
|
111
|
+
const sent = await deliver(sender.id, 'sent', data);
|
|
112
|
+
const delivered = new Set();
|
|
113
|
+
const external = new Set();
|
|
114
|
+
for (const recipient of [...init.to, ...init.cc, ...init.bcc]) {
|
|
115
|
+
const local = await localUser(recipient.address);
|
|
116
|
+
if (!local) {
|
|
117
|
+
external.add(recipient.address.toLowerCase());
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (delivered.has(local.id))
|
|
121
|
+
continue;
|
|
122
|
+
delivered.add(local.id);
|
|
123
|
+
// recipients must not see who was bcc'd
|
|
124
|
+
notifyReceived(await deliver(local.id, 'inbox', { ...data, bcc: [] }));
|
|
125
|
+
}
|
|
126
|
+
for (const recipient of external) {
|
|
127
|
+
await database.insertInto('email_outbound').values({ emailId: sent.id, recipient }).execute();
|
|
128
|
+
}
|
|
129
|
+
if (external.size)
|
|
130
|
+
void processOutbound().catch(e => io.warn('Outbound delivery failed: ' + io.errorText(e)));
|
|
131
|
+
return sent;
|
|
132
|
+
}
|
|
133
|
+
/** Push a message to its mailbox owner over the socket */
|
|
134
|
+
export function notifyReceived(email) {
|
|
135
|
+
socketServer?.to(`user:${email.userId}`).emit('email.received', email);
|
|
136
|
+
}
|
|
137
|
+
const maxAttempts = 8;
|
|
138
|
+
let outboundWorker;
|
|
139
|
+
let processing = false;
|
|
140
|
+
/** Periodically deliver queued outbound messages */
|
|
141
|
+
export function startOutbound() {
|
|
142
|
+
outboundWorker ??= setInterval(() => void processOutbound().catch(e => io.warn('Outbound delivery failed: ' + io.errorText(e))), 60_000);
|
|
143
|
+
outboundWorker.unref();
|
|
144
|
+
}
|
|
145
|
+
export function stopOutbound() {
|
|
146
|
+
clearInterval(outboundWorker);
|
|
147
|
+
outboundWorker = undefined;
|
|
148
|
+
}
|
|
149
|
+
/** Attempt delivery of a single queued message */
|
|
150
|
+
async function attemptDelivery(job, email) {
|
|
151
|
+
try {
|
|
152
|
+
await sendMail(job.recipient, {
|
|
153
|
+
...pick(email, 'subject', 'date', 'read', 'messageId'),
|
|
154
|
+
envelope: { from: email.from.address, to: [job.recipient] },
|
|
155
|
+
from: { name: email.from.name ?? '', address: email.from.address },
|
|
156
|
+
to: email.to.map(m => ({ name: m.name ?? '', address: m.address })),
|
|
157
|
+
cc: email.cc.map(m => ({ name: m.name ?? '', address: m.address })),
|
|
158
|
+
text: email.text ?? undefined,
|
|
159
|
+
html: email.html ?? undefined,
|
|
160
|
+
inReplyTo: email.inReplyTo ?? undefined,
|
|
161
|
+
});
|
|
162
|
+
await database.updateTable('email_outbound').set({ status: 'sent' }).where('id', '=', job.id).execute();
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
const attempts = job.attempts + 1;
|
|
166
|
+
const failed = attempts >= maxAttempts;
|
|
167
|
+
await database
|
|
168
|
+
.updateTable('email_outbound')
|
|
169
|
+
.set({
|
|
170
|
+
attempts,
|
|
171
|
+
status: failed ? 'failed' : 'pending',
|
|
172
|
+
lastError: String(e?.message ?? e),
|
|
173
|
+
nextAttempt: new Date(Date.now() + 5 * 2 ** attempts * 60_000),
|
|
174
|
+
})
|
|
175
|
+
.where('id', '=', job.id)
|
|
176
|
+
.execute();
|
|
177
|
+
if (failed)
|
|
178
|
+
await bounce(email, job.recipient, String(e?.message ?? e));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Deliver a failure notice to the sender */
|
|
182
|
+
async function bounce(email, recipient, reason) {
|
|
183
|
+
notifyReceived(await deliver(email.userId, 'inbox', {
|
|
184
|
+
messageId: `<${crypto.randomUUID()}@${hostname()}>`,
|
|
185
|
+
inReplyTo: email.messageId,
|
|
186
|
+
references: [email.messageId],
|
|
187
|
+
from: { name: 'Mail Delivery', address: `mailing@${hostname()}` },
|
|
188
|
+
to: [email.from],
|
|
189
|
+
cc: [],
|
|
190
|
+
bcc: [],
|
|
191
|
+
subject: 'Delivery Failed',
|
|
192
|
+
text: `Your message to ${recipient} could not be delivered:\n\n${reason}`,
|
|
193
|
+
date: new Date(),
|
|
194
|
+
size: 0,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
export async function processOutbound() {
|
|
198
|
+
if (processing)
|
|
199
|
+
return;
|
|
200
|
+
processing = true;
|
|
201
|
+
try {
|
|
202
|
+
const jobs = await database
|
|
203
|
+
.selectFrom('email_outbound')
|
|
204
|
+
.selectAll()
|
|
205
|
+
.where('status', '=', 'pending')
|
|
206
|
+
.where('nextAttempt', '<=', new Date())
|
|
207
|
+
.limit(20)
|
|
208
|
+
.execute();
|
|
209
|
+
for (const job of jobs) {
|
|
210
|
+
const email = await database.selectFrom('emails').selectAll().where('id', '=', job.emailId).executeTakeFirst();
|
|
211
|
+
if (!email) {
|
|
212
|
+
await database.deleteFrom('email_outbound').where('id', '=', job.id).execute();
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
await attemptDelivery(job, { ...email, attachments: [] });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
processing = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core/plugins';
|
|
2
|
+
import { config, hostname } from '@axium/server/config';
|
|
3
|
+
import * as io from 'ioium/node';
|
|
4
|
+
import { authenticate } from 'mailauth';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import PostalMime, {} from 'postal-mime';
|
|
8
|
+
import { SMTPServer } from 'smtp-server';
|
|
9
|
+
import { Mailbox } from '../common.js';
|
|
10
|
+
import { deliver, localUser, notifyReceived } from './send.js';
|
|
11
|
+
let server;
|
|
12
|
+
/** Convert a parsed address into a valid Mailbox, since we can't trust senders to use valid addresses */
|
|
13
|
+
function toMailbox(parsed) {
|
|
14
|
+
if (!parsed)
|
|
15
|
+
return null;
|
|
16
|
+
const result = Mailbox.safeParse({ name: parsed.name || null, address: 'address' in parsed ? parsed.address : null });
|
|
17
|
+
if (result.success)
|
|
18
|
+
return result.data;
|
|
19
|
+
if (parsed.name)
|
|
20
|
+
return { name: parsed.name, address: `invalid@${hostname()}` };
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function toMailboxes(parsed) {
|
|
24
|
+
return (parsed ?? []).flatMap(address => {
|
|
25
|
+
if ('group' in address && Array.isArray(address.group))
|
|
26
|
+
return address.group.map(toMailbox).filter(m => m != null);
|
|
27
|
+
const mailbox = toMailbox(address);
|
|
28
|
+
return mailbox ? [mailbox] : [];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function handleMessage(stream, session) {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of stream)
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
if (stream.sizeExceeded)
|
|
36
|
+
throw Object.assign(new Error('Message exceeds maximum size'), { responseCode: 552 });
|
|
37
|
+
const raw = Buffer.concat(chunks);
|
|
38
|
+
// SPF/DKIM/DMARC; failures go to spam rather than being rejected, since
|
|
39
|
+
// rejecting after DATA gives senders a bounce with the reason anyway
|
|
40
|
+
let spam = false;
|
|
41
|
+
try {
|
|
42
|
+
const auth = await authenticate(raw, {
|
|
43
|
+
ip: session.remoteAddress,
|
|
44
|
+
helo: session.hostNameAppearsAs,
|
|
45
|
+
sender: session.envelope.mailFrom ? session.envelope.mailFrom.address : undefined,
|
|
46
|
+
mta: hostname(),
|
|
47
|
+
});
|
|
48
|
+
spam = !auth.dmarc || auth.dmarc.status.result == 'fail' || !auth.spf || auth.spf.status.result == 'fail';
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
io.warn('Could not authenticate inbound email: ' + io.errorText(e));
|
|
52
|
+
}
|
|
53
|
+
const parsed = await PostalMime.parse(raw);
|
|
54
|
+
const envelopeFrom = session.envelope.mailFrom ? session.envelope.mailFrom.address : `unknown@${hostname()}`;
|
|
55
|
+
const data = {
|
|
56
|
+
messageId: parsed.messageId || `<${randomUUID()}@${hostname()}>`,
|
|
57
|
+
inReplyTo: parsed.inReplyTo?.trim() || null,
|
|
58
|
+
references: parsed.references?.split(/\s+/).filter(ref => ref.length) ?? [],
|
|
59
|
+
from: toMailbox(parsed.from) ?? { address: envelopeFrom },
|
|
60
|
+
to: toMailboxes(parsed.to),
|
|
61
|
+
cc: toMailboxes(parsed.cc),
|
|
62
|
+
bcc: [],
|
|
63
|
+
subject: parsed.subject ?? '',
|
|
64
|
+
text: parsed.text,
|
|
65
|
+
html: parsed.html,
|
|
66
|
+
date: parsed.date ? new Date(parsed.date) : new Date(),
|
|
67
|
+
size: raw.byteLength,
|
|
68
|
+
};
|
|
69
|
+
const attachments = (parsed.attachments ?? []).map(attachment => ({
|
|
70
|
+
filename: attachment.filename || 'attachment',
|
|
71
|
+
contentType: attachment.mimeType || 'application/octet-stream',
|
|
72
|
+
content: typeof attachment.content == 'string' ? new TextEncoder().encode(attachment.content) : new Uint8Array(attachment.content),
|
|
73
|
+
}));
|
|
74
|
+
const delivered = new Set();
|
|
75
|
+
for (const recipient of session.envelope.rcptTo) {
|
|
76
|
+
const user = await localUser(recipient.address);
|
|
77
|
+
if (!user || delivered.has(user.id))
|
|
78
|
+
continue;
|
|
79
|
+
delivered.add(user.id);
|
|
80
|
+
notifyReceived(await deliver(user.id, spam ? 'spam' : 'inbox', data, attachments));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function tlsOptions() {
|
|
84
|
+
const { inbound } = getConfig('@axium/email');
|
|
85
|
+
const key_file = inbound.ssl_key || config.web.ssl_key;
|
|
86
|
+
const cert_file = inbound.ssl_cert || config.web.ssl_cert;
|
|
87
|
+
try {
|
|
88
|
+
return { key: readFileSync(key_file), cert: readFileSync(cert_file) };
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
io.warn(`Inbound SMTP will not support STARTTLS (${e.message})`);
|
|
92
|
+
return { disabledCommands: ['STARTTLS'] };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function startInbound() {
|
|
96
|
+
if (server)
|
|
97
|
+
return;
|
|
98
|
+
const { inbound } = getConfig('@axium/email');
|
|
99
|
+
if (!inbound.enabled)
|
|
100
|
+
return;
|
|
101
|
+
server = new SMTPServer({
|
|
102
|
+
...tlsOptions(),
|
|
103
|
+
name: hostname(),
|
|
104
|
+
size: inbound.max_size ? inbound.max_size * 1000 : undefined,
|
|
105
|
+
disableReverseLookup: true,
|
|
106
|
+
authOptional: true,
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
108
|
+
async onRcptTo(address, _session, callback) {
|
|
109
|
+
const user = await localUser(address.address);
|
|
110
|
+
if (!user)
|
|
111
|
+
return callback(Object.assign(new Error('Mailbox not found'), { responseCode: 550 }));
|
|
112
|
+
callback();
|
|
113
|
+
},
|
|
114
|
+
onData(stream, session, callback) {
|
|
115
|
+
handleMessage(stream, session).then(() => callback(), (e) => {
|
|
116
|
+
io.warn('Failed to handle inbound email: ' + io.errorText(e));
|
|
117
|
+
e.responseCode ??= 451;
|
|
118
|
+
callback(e);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
server.on('error', e => io.warn('Inbound SMTP error: ' + io.errorText(e)));
|
|
123
|
+
server.listen(inbound.port);
|
|
124
|
+
}
|
|
125
|
+
export function stopInbound() {
|
|
126
|
+
server?.close();
|
|
127
|
+
server = undefined;
|
|
128
|
+
}
|