initial version
This commit is contained in:
commit
ebec73a666
14 changed files with 546 additions and 0 deletions
6
.env.example
Normal file
6
.env.example
Normal 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
11
.gitignore
vendored
Normal 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
BIN
bun.lockb
Executable file
Binary file not shown.
34
package.json
Normal file
34
package.json
Normal 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
|
||||
}
|
29
prisma/migrations/20241125122247_init/migration.sql
Normal file
29
prisma/migrations/20241125122247_init/migration.sql
Normal 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");
|
|
@ -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;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
28
prisma/schema.prisma
Normal 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
247
src/httpServer.ts
Normal 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
24
src/index.ts
Normal 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
100
src/smtpServer.ts
Normal 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
16
src/utils/index.ts
Normal 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
23
src/utils/logs.ts
Normal 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
15
tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
Reference in a new issue