initial version
This commit is contained in:
commit
ebec73a666
14 changed files with 546 additions and 0 deletions
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()),
|
||||
}),
|
||||
],
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue