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…
	
	Add table
		Add a link
		
	
		Reference in a new issue