commit ebec73a666b317aef2ad835deb6a6946e0f44b19 Author: Danny Morabito Date: Mon Dec 2 14:28:00 2024 +0100 initial version diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7af7356 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1ff2da --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea/* +.vscode/* +/tmp +/node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.DS_Store +.env +users.db \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..57b31f4 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f7d950 --- /dev/null +++ b/package.json @@ -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 +} diff --git a/prisma/migrations/20241125122247_init/migration.sql b/prisma/migrations/20241125122247_init/migration.sql new file mode 100644 index 0000000..aa6b44d --- /dev/null +++ b/prisma/migrations/20241125122247_init/migration.sql @@ -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"); diff --git a/prisma/migrations/20241126193747_remove_mail_queue/migration.sql b/prisma/migrations/20241126193747_remove_mail_queue/migration.sql new file mode 100644 index 0000000..a0e1043 --- /dev/null +++ b/prisma/migrations/20241126193747_remove_mail_queue/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..42ccefa --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/httpServer.ts b/src/httpServer.ts new file mode 100644 index 0000000..e56b85e --- /dev/null +++ b/src/httpServer.ts @@ -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 + }) => { + 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 + }) => { + 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, 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); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7d96c8f --- /dev/null +++ b/src/index.ts @@ -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')); \ No newline at end of file diff --git a/src/smtpServer.ts b/src/smtpServer.ts new file mode 100644 index 0000000..ced5372 --- /dev/null +++ b/src/smtpServer.ts @@ -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}`); + } +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..b220d99 --- /dev/null +++ b/src/utils/index.ts @@ -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, + }); +} diff --git a/src/utils/logs.ts b/src/utils/logs.ts new file mode 100644 index 0000000..80c0465 --- /dev/null +++ b/src/utils/logs.ts @@ -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()), + }), + ], +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..83c5747 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "preserve", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "allowImportingTsExtensions": true + }, + "include": [ + "src" + ] +}