initial version

This commit is contained in:
Danny Morabito 2024-12-02 14:28:00 +01:00
commit ebec73a666
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
14 changed files with 546 additions and 0 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
BASE_DOMAIN=npub.email
DB_URL=file:./users.db
SMTP_PORT=6587
HTTP_PORT=3000
LOG_FILE=/tmp/nostr-email.log
PUBLIC_API_BASE_URL=https://api.npub.email

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.idea/*
.vscode/*
/tmp
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.env
users.db

BIN
bun.lockb Executable file

Binary file not shown.

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "mail-server",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"dev": "DEBUG='ndk:*' bun --watch src/index.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev"
},
"dependencies": {
"@arx/utils": "git+ssh://git@git.arx-ccn.com:222/Arx/ts-utils#v0.0.4",
"@elysiajs/cors": "^1.1.1",
"@elysiajs/server-timing": "^1.1.0",
"@elysiajs/swagger": "^1.1.6",
"@libsql/client": "^0.14.0",
"@nostr-dev-kit/ndk": "^2.10.6",
"@prisma/adapter-libsql": "^5.22.0",
"@prisma/client": "5.22.0",
"elysia": "^1.1.25",
"node-forge": "^1.3.1",
"smtp-server": "^3.13.0",
"websocket-polyfill": "^1.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node-forge": "^1.3.9",
"@types/smtp-server": "^3.5.10",
"bun-types": "latest",
"prisma": "5.22.0",
"typescript": "^5.3.2"
},
"private": true
}

View file

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "users" (
"npub" TEXT NOT NULL PRIMARY KEY,
"registeredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastPayment" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subscriptionDuration" INTEGER
);
-- CreateTable
CREATE TABLE "aliases" (
"npub" TEXT NOT NULL,
"alias" TEXT NOT NULL,
PRIMARY KEY ("npub", "alias"),
CONSTRAINT "aliases_npub_fkey" FOREIGN KEY ("npub") REFERENCES "users" ("npub") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "mail_queue" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"alias" TEXT NOT NULL,
"sender" TEXT NOT NULL,
"data" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mail_queue_alias_fkey" FOREIGN KEY ("alias") REFERENCES "aliases" ("alias") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "aliases_alias_key" ON "aliases"("alias");

View file

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the `mail_queue` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "mail_queue";
PRAGMA foreign_keys=on;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

28
prisma/schema.prisma Normal file
View file

@ -0,0 +1,28 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "sqlite"
url = env("DB_URL")
}
model User {
npub String @id
registeredAt DateTime @default(now())
lastPayment DateTime @default(now())
subscriptionDuration Int?
aliases Alias[]
@@map("users")
}
model Alias {
npub String
alias String @unique
user User @relation(fields: [npub], references: [npub])
@@id([npub, alias])
@@map("aliases")
}

247
src/httpServer.ts Normal file
View file

@ -0,0 +1,247 @@
import {CashuMint, CashuWallet, getEncodedToken} from "@cashu/cashu-ts";
import {logger} from "./utils";
import * as nip98 from "nostr-tools/nip98";
import {Elysia, t} from "elysia";
import {swagger} from "@elysiajs/swagger";
import {serverTiming} from "@elysiajs/server-timing";
import {PrismaClient} from "@prisma/client";
import {TokenInfoWithMailSubscriptionDuration} from "@arx/utils/cashu.ts";
import {npubToPubKeyString, pubKeyStringToNpub} from "@arx/utils/nostr.ts";
import cors from "@elysiajs/cors";
const npubType = t.String({
pattern: `^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$`,
error: 'Invalid npub format'
});
const cashuTokenType = t.String({
pattern: '^cashu[A-Za-z0-9+-_]*={0,3}$',
error: 'Invalid Cashu token format'
})
export class HttpServer {
constructor(private db: PrismaClient, port: number) {
new Elysia()
.use(swagger({
documentation: {
info: {
title: 'npub.email Documentation',
version: '0.0.1'
}
}
}))
.use(serverTiming())
.use(cors())
.get('/', 'nostr.email server')
.get('/subscription/:npub', this.getSubscriptionForNpub, {
params: t.Object({
npub: npubType
})
})
.get('/aliases/:npub', this.getAliasesForNpub, {
params: t.Object({
npub: npubType,
}),
})
.get('/alias/:alias', this.getNpubForAlias, {
params: t.Object({
alias: t.String(),
}),
})
.post('/addAlias', this.addAlias, {
body: t.Object({
alias: t.String()
})
})
.post('/addTime/:npub', this.addTimeToNpub, {
params: t.Object({
npub: npubType,
}),
body: t.Object({
tokenString: cashuTokenType
})
})
.listen(port)
logger.info(`HTTP Server running on port ${port}`);
}
getSubscriptionForNpub = async ({params: {npub}}: {
params: {
npub: string
}
}) => {
const user = await this.db.user.findFirst({
where: {
npub
},
include: {
aliases: true
}
});
if (!user) return {
subscribed: false
};
return {
subscribed: true,
subscribedUntil: user.subscriptionDuration == null ? Infinity : Math.floor(user.lastPayment.getTime() / 1000) + user.subscriptionDuration
};
}
getNpubForAlias = async ({params: {alias}}: {
params: {
alias: string
}
}) => {
const user = await this.db.user.findFirst({
where: {
aliases: {
some: {
alias
}
}
}
});
if (!user) return new Response('Not found', {
status: 404
});
return user.npub;
}
getAliasesForNpub = async ({params: {npub}, headers}: {
params: {
npub: string
},
headers: Record<string, string | undefined>
}) => {
const unpacked = await this.getUnpackedAuthHeader(headers, `/aliases/${npub}`);
const npubAsPubkey = npubToPubKeyString(npub);
if (unpacked.pubkey !== npubAsPubkey)
return new Response('Unauthorized', {
status: 401
})
const user = await this.db.user.findFirst({
where: {
npub
},
include: {
aliases: true
}
});
if (!user) return new Response('Not found', {
status: 404
});
return user.aliases.map(alias => alias.alias);
}
addAlias = async ({body: {alias}, headers}: {
body: {
alias: string
},
headers: Record<string, string | undefined>
}) => {
const unpacked = await this.getUnpackedAuthHeader(headers, '/addAlias');
const unpackedKeyToNpub = pubKeyStringToNpub(unpacked.pubkey);
const userInDb = await this.db.user.findFirst({
where: {
npub: unpackedKeyToNpub
}
});
if (!userInDb) return new Response('Unauthorized', {
status: 401
});
const stillHasSubscription = userInDb.subscriptionDuration === null || Math.floor(userInDb.lastPayment.getTime() / 1000) + userInDb.subscriptionDuration > Date.now() / 1000;
if (!stillHasSubscription) return new Response('User has no subscription', {
status: 400
});
const aliasInDb = await this.db.alias.findFirst({
where: {
alias
}
});
if (aliasInDb) return new Response('Alias already exists', {
status: 400
});
return this.db.user.update({
where: {
npub: unpackedKeyToNpub
},
data: {
aliases: {
create: {
alias
}
}
}
});
}
addTimeToNpub = async ({params: {npub}, body: {tokenString}}: {
params: {
npub: string
},
body: {
tokenString: string
}
}) => {
const userInDb = await this.db.user.findFirst({
where: {
npub
}
});
if (userInDb && (userInDb.subscriptionDuration === null || userInDb.subscriptionDuration === -1))
return new Response('User has unlimited subscription', {
status: 400
})
const tokenInfo = new TokenInfoWithMailSubscriptionDuration(tokenString);
const mint = new CashuMint(tokenInfo.mint);
const wallet = new CashuWallet(mint);
const newToken = await wallet.receive(tokenString);
const encodedToken = getEncodedToken({
token: [{
mint: tokenInfo.mint,
proofs: newToken
}]
});
logger.info(`New cashu token: ${encodedToken}`);
if (userInDb) {
let timeRemaining = Math.max(0, Math.floor((+new Date(userInDb.lastPayment.getTime() + userInDb.subscriptionDuration! * 1000) - +new Date()) / 1000));
timeRemaining += tokenInfo.duration;
await this.db.user.update({
where: {
npub
},
data: {
lastPayment: new Date(),
subscriptionDuration: timeRemaining
}
});
return {
newTimeRemaining: timeRemaining
}
}
await this.db.user.create({
data: {
npub,
registeredAt: new Date(),
lastPayment: new Date(),
subscriptionDuration: tokenInfo.duration
}
});
return {
newTimeRemaining: tokenInfo.duration
}
}
private getUnpackedAuthHeader = async (headers: Record<string, string | undefined>, url: string) => {
if (!headers.authorization)
throw new Error('Unauthorized');
const authHeader = headers.authorization.split(' ')[1];
const validate = await nip98.validateToken(authHeader, `${process.env.PUBLIC_API_BASE_URL!}${url}`, "POST");
if (!validate)
throw new Error('Unauthorized');
return await nip98.unpackEventFromToken(authHeader);
}
}

24
src/index.ts Normal file
View file

@ -0,0 +1,24 @@
import {createClient as createLibSQLClient} from "@libsql/client";
import "websocket-polyfill";
import {PrismaClient} from "@prisma/client";
import {PrismaLibSQL} from "@prisma/adapter-libsql";
import {NostrSmtpServer} from "./smtpServer";
import {HttpServer} from "./httpServer";
if (!process.env.BASE_DOMAIN)
throw new Error("BASE_DOMAIN is not set");
if (!process.env.DB_URL)
throw new Error("DB_URL is not set");
if (!process.env.PUBLIC_API_BASE_URL)
throw new Error("PUBLIC_API_BASE_URL is not set");
const dbClient = createLibSQLClient({
url: process.env.DB_URL,
});
const db = new PrismaClient({
adapter: new PrismaLibSQL(dbClient)
});
new NostrSmtpServer(db, parseInt(process.env.SMTP_PORT || '6587'));
new HttpServer(db, parseInt(process.env.HTTP_PORT || '3000'));

100
src/smtpServer.ts Normal file
View file

@ -0,0 +1,100 @@
import {SMTPServer} from "smtp-server";
import {getNDK} from "./utils";
import {generateSecretKey} from "nostr-tools";
import {NDKEvent, NDKKind, NDKPrivateKeySigner} from "@nostr-dev-kit/ndk";
import {PrismaClient} from "@prisma/client";
import {logger} from "./utils/logs";
import {encryptEventForRecipient, parseEmail} from "@arx/utils";
export class NostrSmtpServer {
private server: SMTPServer;
constructor(db: PrismaClient, port: number) {
this.server = new SMTPServer({
authOptional: true,
logger: false,
onData: (stream, session, callback) => {
let mailData = '';
stream.on('data', (chunk: Buffer) => {
mailData += chunk.toString();
});
stream.on('end', async () => {
if (!session.envelope.mailFrom) {
logger.warn('Ignoring email without sender');
callback();
return;
}
try {
const parsedEmail = parseEmail(mailData);
for (let recipientEmail of session.envelope.rcptTo) {
const address = recipientEmail.address;
const parts = address.split('@');
if (parts[1] !== process.env.BASE_DOMAIN) {
logger.warn('Not sending email to', address, 'because it is not in the allowed domain');
continue;
}
const alias = parts[0];
const user = await db.alias.findUnique({
where: {
alias
},
include: {
user: true
}
});
if (!user) {
logger.warn('No user found for', alias, 'skipping');
continue;
}
const timeRemainingInSubscription = user.user.subscriptionDuration === null ? Infinity : (user.user.subscriptionDuration * 1000) - Date.now() + user.user.lastPayment.getTime();
if (timeRemainingInSubscription <= 0) {
logger.warn(`Subscription has expired for ${alias}`);
continue;
}
const recipient = user.npub;
const randomKey = generateSecretKey();
const randomKeySinger = new NDKPrivateKeySigner(randomKey);
const ndk = getNDK();
ndk.signer = randomKeySinger;
await ndk.connect();
const ndkUser = ndk.getUser({
npub: recipient
});
const randomKeyUser = await randomKeySinger.user();
const event = new NDKEvent();
event.kind = NDKKind.Article;
event.content = parsedEmail.body;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = randomKeyUser.pubkey;
event.tags.push(['p', ndkUser.pubkey])
event.tags.push(['subject', parsedEmail.subject]);
event.tags.push(['email:localIP', session.localAddress]);
event.tags.push(['email:remoteIP', session.remoteAddress]);
event.tags.push(['email:isEmail', 'true']);
for (let to of session.envelope.rcptTo)
event.tags.push(['email:to', to.address]);
for (let header of Object.keys(parsedEmail.headers))
event.tags.push([`email:header:${header}`, parsedEmail.headers[header]]);
event.tags.push(['email:session', session.id]);
event.tags.push(['email:from', session.envelope.mailFrom?.address ?? '']);
await event.sign(randomKeySinger);
const encryptedEvent = await encryptEventForRecipient(ndk, event, ndkUser);
await encryptedEvent.publish();
}
} catch (e) {
logger.error(JSON.stringify(e));
} finally {
callback();
}
});
}
});
this.server.listen(port, '0.0.0.0');
logger.info(`SMTP Server running on port ${port}`);
}
}

16
src/utils/index.ts Normal file
View file

@ -0,0 +1,16 @@
import NDK from "@nostr-dev-kit/ndk";
export * from "./logs";
export function getNDK() {
return new NDK({
explicitRelayUrls: [
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://offchain.pub'
],
autoConnectUserRelays: false,
enableOutboxModel: true,
});
}

23
src/utils/logs.ts Normal file
View file

@ -0,0 +1,23 @@
import winston from "winston";
const {combine, timestamp, printf, align, colorize, json} = winston.format;
export const logger = winston.createLogger({
level: 'info',
transports: [
new winston.transports.Console({
format: combine(
colorize({all: true}),
timestamp({
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
}),
align(),
printf((info) => `[${info.timestamp}] ${info.level}: ${info.message}`)
),
}),
new winston.transports.File({
filename: process.env.LOG_FILE || '/tmp/nostr-email.log',
format: combine(timestamp(), json()),
}),
],
});

15
tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"allowImportingTsExtensions": true
},
"include": [
"src"
]
}