Compare commits
24 Commits
v1.0.0
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
| eeed88eba4 | |||
| e46cb018fd | |||
| a8d3bf1b3b | |||
|
|
6e75be19ed | ||
|
|
f7e9d5c494 | ||
|
|
c0caedd6fa | ||
| 2afe8e39b9 | |||
|
|
8ed813dd53 | ||
| 905054e3b5 | |||
| 271fa45937 | |||
| a38ddc727f | |||
|
|
72019caca2 | ||
|
|
449277be21 | ||
|
|
77211d58cc | ||
|
|
5673870649 | ||
|
|
f11299ed93 | ||
| 11c26df116 | |||
|
|
2752045c16 | ||
|
|
2616728128 | ||
|
|
a8de784981 | ||
| 867e6e65a5 | |||
| e39026c259 | |||
|
|
5c75e9390e | ||
|
|
cc71d9be21 |
44
.env.example
@@ -1,39 +1,19 @@
|
|||||||
ADMIN_EMAIL=""
|
ADMIN_EMAIL=""
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
HOME_URL=""
|
HOME_URL=""
|
||||||
MAINTENANCE_MODE=""
|
OVHCLOUD_API_KEY=""
|
||||||
NEWS_LIMIT=""
|
MAINTENANCE_MODE="0"
|
||||||
|
NEWS_LIMIT="50"
|
||||||
|
NEWS_TO_USE="10"
|
||||||
NEXT_PUBLIC_BRAND_COUNTRY=""
|
NEXT_PUBLIC_BRAND_COUNTRY=""
|
||||||
NEXT_PUBLIC_BRAND_EMAIL=""
|
NEXT_PUBLIC_BRAND_EMAIL=""
|
||||||
NEXT_PUBLIC_BRAND_NAME=""
|
NEXT_PUBLIC_BRAND_NAME=""
|
||||||
NX_DAEMON=""
|
DATABASE_URL=""
|
||||||
POSTGRES_DATABASE=""
|
|
||||||
POSTGRES_HOST=""
|
|
||||||
POSTGRES_PASSWORD=""
|
|
||||||
POSTGRES_PRISMA_URL=""
|
|
||||||
POSTGRES_URL=""
|
|
||||||
POSTGRES_URL_NON_POOLING=""
|
|
||||||
POSTGRES_USER=""
|
|
||||||
RESEND_AUDIENCE=""
|
|
||||||
RESEND_FROM=""
|
|
||||||
RESEND_KEY=""
|
|
||||||
SECRET_HASH=""
|
SECRET_HASH=""
|
||||||
TURBO_REMOTE_ONLY=""
|
|
||||||
TURBO_RUN_SUMMARY=""
|
EMAIL_HOST="postfix"
|
||||||
VERCEL="1"
|
EMAIL_PORT="25"
|
||||||
VERCEL_ENV="development"
|
EMAIL_FROM=""
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
|
MAIL_DOMAIN=""
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
|
MAIL_HOSTNAME=""
|
||||||
VERCEL_GIT_COMMIT_MESSAGE=""
|
DKIM_SELECTOR="mail"
|
||||||
VERCEL_GIT_COMMIT_REF=""
|
|
||||||
VERCEL_GIT_COMMIT_SHA=""
|
|
||||||
VERCEL_GIT_PREVIOUS_SHA=""
|
|
||||||
VERCEL_GIT_PROVIDER=""
|
|
||||||
VERCEL_GIT_PULL_REQUEST_ID=""
|
|
||||||
VERCEL_GIT_REPO_ID=""
|
|
||||||
VERCEL_GIT_REPO_OWNER=""
|
|
||||||
VERCEL_GIT_REPO_SLUG=""
|
|
||||||
VERCEL_URL=""
|
|
||||||
ANALYZE="false"
|
|
||||||
ANTHROPIC_API_KEY=""
|
|
||||||
NEWS_TO_USE=10
|
|
||||||
|
|||||||
41
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Deploy to VPS
|
||||||
|
env:
|
||||||
|
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
|
||||||
|
ssh debian@51.210.247.57 << 'EOF'
|
||||||
|
cd /home/debian/newsletter-hackernews
|
||||||
|
git pull origin main
|
||||||
|
cd /home/debian/gitea
|
||||||
|
docker compose up -d --build newsletter
|
||||||
|
EOF
|
||||||
86
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-typecheck:
|
||||||
|
name: Lint & Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: yarn typecheck
|
||||||
|
|
||||||
|
- name: Run Prettier check
|
||||||
|
run: yarn format
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-and-typecheck
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Report deployment readiness
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: echo "✅ All checks passed - ready for deployment"
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
name: Commit Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate commit messages
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
yarn commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||||
|
else
|
||||||
|
yarn commitlint --from HEAD~1 --to HEAD --verbose
|
||||||
|
fi
|
||||||
14
Dockerfile
@@ -4,12 +4,20 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn build
|
ARG NEXT_PUBLIC_BRAND_NAME
|
||||||
|
ARG NEXT_PUBLIC_BRAND_EMAIL
|
||||||
|
ARG NEXT_PUBLIC_BRAND_COUNTRY
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
|
||||||
|
ENV NEXT_PUBLIC_BRAND_EMAIL=$NEXT_PUBLIC_BRAND_EMAIL
|
||||||
|
ENV NEXT_PUBLIC_BRAND_COUNTRY=$NEXT_PUBLIC_BRAND_COUNTRY
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "npm", "start" ]
|
||||||
85
README.md
@@ -1,54 +1,87 @@
|
|||||||
# Hackernews newsletter
|
# 📰 HackerNews Newsletter
|
||||||
|
|
||||||
## Future improvements
|
A Next.js application that aggregates HackerNews stories and delivers them as personalized daily newsletters.
|
||||||
|
|
||||||
- Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
|
## ✨ Features
|
||||||
|
|
||||||
## Some resources used
|
- 📧 Daily newsletter containing curated HackerNews stories
|
||||||
|
- ☁️ Vercel hosting integration
|
||||||
|
|
||||||
https://gradientbuttons.colorion.co/
|
## 🚀 Future improvements
|
||||||
https://codepen.io/alphardex/pen/vYEYGzp
|
|
||||||
|
|
||||||
## Commands
|
- ⏰ Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
|
||||||
|
|
||||||
Install vercel cli
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
- ⚡ Next.js
|
||||||
|
- 🗄️ Prisma (Database ORM)
|
||||||
|
- 🚀 Vercel (Hosting)
|
||||||
|
- 📝 Custom email templates
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### 📋 Prerequisites
|
||||||
|
|
||||||
|
- 📦 Node.js
|
||||||
|
- 🐳 Docker
|
||||||
|
- 🔧 Vercel CLI
|
||||||
|
- 🧶 Yarn package manager
|
||||||
|
|
||||||
|
### 💻 Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/RiccardoSenica/newsletter-hackernews
|
||||||
|
cd hackernews-newsletter
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up Vercel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Vercel CLI
|
||||||
yarn add -g vercel@latest
|
yarn add -g vercel@latest
|
||||||
```
|
|
||||||
|
|
||||||
Link to vercel
|
# Link to your Vercel project
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn vercel:link
|
yarn vercel:link
|
||||||
```
|
|
||||||
|
|
||||||
Pull env variables from vercel
|
# Pull environment variables
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn vercel:env
|
yarn vercel:env
|
||||||
```
|
```
|
||||||
|
|
||||||
Push Prisma schema to vercel
|
4. Set up the database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Push Prisma schema to database
|
||||||
yarn db:push
|
yarn db:push
|
||||||
```
|
|
||||||
|
|
||||||
Generate Prisma client
|
# Generate Prisma client
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn prisma:generate
|
yarn prisma:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Reset Prisma database
|
### 🔧 Development
|
||||||
|
|
||||||
|
Run locally with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🗄️ Database Management
|
||||||
|
|
||||||
|
Reset database (⚠️ caution: this will delete all data):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn db:reset
|
yarn db:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
Run on Docker
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
```bash
|
- 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/)
|
||||||
docker-compose up --build
|
- ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp)
|
||||||
```
|
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import {
|
|||||||
} from '@utils/statusCodes';
|
} from '@utils/statusCodes';
|
||||||
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
|
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
export const dynamic = 'force-dynamic'; // defaults to force-static
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
if (!process.env.RESEND_KEY) {
|
||||||
throw new Error('Resend variables not set');
|
throw new Error('Resend variables not set');
|
||||||
}
|
}
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -31,14 +30,6 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: false
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
code: validation.data.code
|
code: validation.data.code
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
|
import axios from 'axios';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import {
|
import {
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
import { getSingleNews, getTopNews } from '@utils/urls';
|
import { getSingleNews, getTopNews } from '@utils/urls';
|
||||||
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
|
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (
|
if (
|
||||||
@@ -19,17 +19,22 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const topStories: number[] = await fetch(getTopNews, {
|
const { data: topStories } = await axios.get<number[]>(getTopNews, {
|
||||||
cache: 'no-store'
|
headers: {
|
||||||
}).then(res => res.json());
|
'Cache-Control': 'no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
console.info(`Top stories ids: ${topStories}`);
|
console.info(`Top stories ids: ${topStories}`);
|
||||||
|
|
||||||
const newsPromises = topStories
|
const newsPromises = topStories
|
||||||
.slice(0, Number(process.env.NEWS_LIMIT))
|
.slice(0, Number(process.env.NEWS_LIMIT))
|
||||||
.map(id => fetch(getSingleNews(id)).then(res => res.json()));
|
.map(id => axios.get<NewsDatabaseType>(getSingleNews(id)));
|
||||||
|
|
||||||
const news: NewsDatabaseType[] = await Promise.all(newsPromises);
|
const newsResponses = await Promise.all(newsPromises);
|
||||||
|
const news: NewsDatabaseType[] = newsResponses.map(
|
||||||
|
response => response.data
|
||||||
|
);
|
||||||
|
|
||||||
const upsertPromises = news.map(async getSingleNews => {
|
const upsertPromises = news.map(async getSingleNews => {
|
||||||
const validation = NewsDatabaseSchema.safeParse(getSingleNews);
|
const validation = NewsDatabaseSchema.safeParse(getSingleNews);
|
||||||
@@ -65,23 +70,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
console.info(`Imported ${result.length} news.`);
|
console.info(`Imported ${result.length} news.`);
|
||||||
|
|
||||||
if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.emails.send({
|
|
||||||
from: process.env.RESEND_FROM,
|
|
||||||
to: [process.env.ADMIN_EMAIL],
|
|
||||||
subject: 'Newsletter: import cron job',
|
|
||||||
text: `Imported ${result.length} news out of these ids: ${topStories.join(', ')}.`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatApiResponse(
|
return formatApiResponse(
|
||||||
STATUS_OK,
|
STATUS_OK,
|
||||||
`Imported ${newsPromises.length} news.`
|
`Imported ${newsPromises.length} news.`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
} else {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
return formatApiResponse(
|
return formatApiResponse(
|
||||||
STATUS_INTERNAL_SERVER_ERROR,
|
STATUS_INTERNAL_SERVER_ERROR,
|
||||||
INTERNAL_SERVER_ERROR
|
INTERNAL_SERVER_ERROR
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NewsletterTemplate } from '@components/email/Newsletter';
|
import { NewsletterTemplate } from '@components/email/Newsletter';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/mailer';
|
||||||
import {
|
import {
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
STATUS_INTERNAL_SERVER_ERROR,
|
STATUS_INTERNAL_SERVER_ERROR,
|
||||||
@@ -25,9 +25,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes
|
|
||||||
// this is to avoid sending the newsletter to the same users multiple times
|
|
||||||
// this is not a perfect solution, but it's good enough for now
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConfirmationTemplate } from '@components/email/Confirmation';
|
import { ConfirmationTemplate } from '@components/email/Confirmation';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/mailer';
|
||||||
import {
|
import {
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -12,14 +12,9 @@ import {
|
|||||||
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
|
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
|
||||||
throw new Error('RESEND_KEY is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const validation = SubscribeFormSchema.safeParse(body);
|
const validation = SubscribeFormSchema.safeParse(body);
|
||||||
@@ -36,8 +31,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
const code = crypto
|
const code = crypto
|
||||||
.createHash('sha256')
|
.createHash('sha256')
|
||||||
.update(`${process.env.SECRET_HASH}${email}}`)
|
.update(`${process.env.SECRET_HASH}${email}}`)
|
||||||
@@ -53,19 +46,6 @@ export async function POST(request: NextRequest) {
|
|||||||
deleted: false
|
deleted: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const contact = await resend.contacts.get({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: ResponseType = {
|
const message: ResponseType = {
|
||||||
@@ -84,21 +64,10 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const contact = await resend.contacts.create({
|
|
||||||
email: email,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!contact.data?.id) {
|
|
||||||
throw new Error('Failed to create Resend contact');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
code,
|
code
|
||||||
resendId: contact.data.id
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
|
import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
|
||||||
import prisma from '@prisma/prisma';
|
import prisma from '@prisma/prisma';
|
||||||
import { formatApiResponse } from '@utils/formatApiResponse';
|
import { formatApiResponse } from '@utils/formatApiResponse';
|
||||||
import { sender } from '@utils/resendClient';
|
import { sender } from '@utils/mailer';
|
||||||
import {
|
import {
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
INTERNAL_SERVER_ERROR,
|
INTERNAL_SERVER_ERROR,
|
||||||
@@ -11,17 +11,13 @@ import {
|
|||||||
} from '@utils/statusCodes';
|
} from '@utils/statusCodes';
|
||||||
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
|
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'; // defaults to force-static
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
|
|
||||||
throw new Error('RESEND_AUDIENCE is not set');
|
|
||||||
}
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const validation = UnsubscribeFormSchema.safeParse(body);
|
const validation = UnsubscribeFormSchema.safeParse(body);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
|
||||||
}
|
}
|
||||||
@@ -44,14 +40,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
await resend.contacts.update({
|
|
||||||
id: user.resendId,
|
|
||||||
audienceId: process.env.RESEND_AUDIENCE,
|
|
||||||
unsubscribed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = await sender([email], UnsubscribeTemplate());
|
const sent = await sender([email], UnsubscribeTemplate());
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CardDescription } from '@components/Card';
|
|||||||
import { CustomCard } from '@components/CustomCard';
|
import { CustomCard } from '@components/CustomCard';
|
||||||
import { SchemaOrg } from '@components/SchemaOrg';
|
import { SchemaOrg } from '@components/SchemaOrg';
|
||||||
import { ResponseType } from '@utils/validationSchemas';
|
import { ResponseType } from '@utils/validationSchemas';
|
||||||
|
import axios from 'axios';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -23,32 +24,31 @@ const ConfirmationPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/confirmation', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/confirmation',
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: code
|
code: code
|
||||||
})
|
},
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!data.success) {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response: ResponseType = await res.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!response.success) {
|
|
||||||
router.push('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(response.message);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
} else {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
34
app/page.tsx
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@utils/validationSchemas';
|
} from '@utils/validationSchemas';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
@@ -45,29 +46,28 @@ export const Home = () => {
|
|||||||
async function handleSubmit(values: SubscribeFormType) {
|
async function handleSubmit(values: SubscribeFormType) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/subscribe', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/subscribe',
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: values.email
|
email: values.email
|
||||||
})
|
},
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response?.ok) {
|
if (!data.success) {
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formResponse: ResponseType = await response.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!formResponse.success) {
|
|
||||||
throw Error(formResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(formResponse.message);
|
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -2,8 +2,21 @@
|
|||||||
|
|
||||||
import { CustomCard } from '@components/CustomCard';
|
import { CustomCard } from '@components/CustomCard';
|
||||||
import { SchemaOrg } from '@components/SchemaOrg';
|
import { SchemaOrg } from '@components/SchemaOrg';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const useObfuscatedEmail = () => {
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEmail(process.env.NEXT_PUBLIC_BRAND_EMAIL || null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return email;
|
||||||
|
};
|
||||||
|
|
||||||
const Privacy = () => {
|
const Privacy = () => {
|
||||||
|
const email = useObfuscatedEmail();
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
@@ -431,12 +444,16 @@ const Privacy = () => {
|
|||||||
<p className='leading-relaxed'>
|
<p className='leading-relaxed'>
|
||||||
If you have any questions about this Privacy Policy, You can contact us
|
If you have any questions about this Privacy Policy, You can contact us
|
||||||
by writing to{' '}
|
by writing to{' '}
|
||||||
<a
|
{email ? (
|
||||||
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
<a
|
||||||
className='text-purple-600 hover:text-purple-700'
|
href={`mailto:${email}`}
|
||||||
>
|
className='text-purple-600 hover:text-purple-700'
|
||||||
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
|
>
|
||||||
</a>
|
{email}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className='text-gray-400'>loading...</span>
|
||||||
|
)}
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
UnsubscribeFormSchema,
|
UnsubscribeFormSchema,
|
||||||
UnsubscribeFormType
|
UnsubscribeFormType
|
||||||
} from '@utils/validationSchemas';
|
} from '@utils/validationSchemas';
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -52,29 +54,28 @@ const Unsubscribe = () => {
|
|||||||
async function handleSubmit(values: UnsubscribeFormType) {
|
async function handleSubmit(values: UnsubscribeFormType) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/unsubscribe', {
|
const { data } = await axios.post<ResponseType>(
|
||||||
method: 'POST',
|
'/api/unsubscribe',
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: values.email
|
email: values.email
|
||||||
})
|
},
|
||||||
});
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response?.ok) {
|
if (!data.success) {
|
||||||
throw new Error(`Invalid response: ${response.status}`);
|
throw new Error(data.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formResponse: ResponseType = await response.json();
|
setMessage(data.message);
|
||||||
|
|
||||||
if (!formResponse.success) {
|
|
||||||
throw Error(formResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(formResponse.message);
|
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error('Axios error:', error.response?.data || error.message);
|
||||||
|
}
|
||||||
setError(true);
|
setError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { summirize } from '@utils/anthropic/summarize';
|
import { summirize } from '@utils/ai/summarize';
|
||||||
import { NewsType } from '@utils/validationSchemas';
|
import { NewsType } from '@utils/validationSchemas';
|
||||||
import createDOMPurify from 'isomorphic-dompurify';
|
import createDOMPurify from 'isomorphic-dompurify';
|
||||||
import { Template } from './Template';
|
import { Template } from './Template';
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import {
|
const iconStyle = {
|
||||||
User,
|
display: 'inline-block',
|
||||||
Building2,
|
verticalAlign: 'middle'
|
||||||
Mail,
|
};
|
||||||
LogOut,
|
|
||||||
LayoutGrid,
|
const Icon = ({ name, size = 16 }: { name: string; size?: number }) => (
|
||||||
Shield,
|
<img
|
||||||
Home
|
src={`${process.env.HOME_URL}/email-icons/${name}.png`}
|
||||||
} from 'lucide-react';
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt=""
|
||||||
|
style={iconStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +49,7 @@ export const Footer = () => {
|
|||||||
letterSpacing: '0.05em'
|
letterSpacing: '0.05em'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<User size={16} color='#386FA4' />
|
<Icon name="user" size={16} />
|
||||||
Contact Us
|
Contact Us
|
||||||
</h4>
|
</h4>
|
||||||
<p
|
<p
|
||||||
@@ -57,7 +62,7 @@ export const Footer = () => {
|
|||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Building2 size={14} color='#386FA4' />
|
<Icon name="building-2" size={14} />
|
||||||
{process.env.NEXT_PUBLIC_BRAND_NAME}
|
{process.env.NEXT_PUBLIC_BRAND_NAME}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
@@ -70,7 +75,7 @@ export const Footer = () => {
|
|||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Mail size={14} color='#386FA4' />
|
<Icon name="mail" size={14} />
|
||||||
<a
|
<a
|
||||||
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
@@ -88,7 +93,7 @@ export const Footer = () => {
|
|||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogOut size={14} color='#386FA4' />
|
<Icon name="log-out" size={14} />
|
||||||
<span>
|
<span>
|
||||||
Click{' '}
|
Click{' '}
|
||||||
<a
|
<a
|
||||||
@@ -124,7 +129,7 @@ export const Footer = () => {
|
|||||||
letterSpacing: '0.05em'
|
letterSpacing: '0.05em'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutGrid size={16} color='#386FA4' />
|
<Icon name="layout-grid" size={16} />
|
||||||
Quick Links
|
Quick Links
|
||||||
</h4>
|
</h4>
|
||||||
<p
|
<p
|
||||||
@@ -137,7 +142,7 @@ export const Footer = () => {
|
|||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Shield size={14} color='#386FA4' />
|
<Icon name="shield" size={14} />
|
||||||
<a
|
<a
|
||||||
href={`${process.env.HOME_URL}/privacy`}
|
href={`${process.env.HOME_URL}/privacy`}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
@@ -155,7 +160,7 @@ export const Footer = () => {
|
|||||||
color: '#4A5568'
|
color: '#4A5568'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Home size={14} color='#386FA4' />
|
<Icon name="house" size={14} />
|
||||||
<a
|
<a
|
||||||
href={process.env.HOME_URL}
|
href={process.env.HOME_URL}
|
||||||
style={{ color: '#386FA4', textDecoration: 'none' }}
|
style={{ color: '#386FA4', textDecoration: 'none' }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NewsTileType } from '@utils/validationSchemas';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Tile } from './components/Tile';
|
import { Tile } from './components/Tile';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface TilesProps {
|
interface TilesProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -22,11 +23,19 @@ export const Tiles = ({ children }: TilesProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function getNews() {
|
async function getNews() {
|
||||||
const news: NewsTileType[] = await fetch('/api/news').then(res =>
|
try {
|
||||||
res.json()
|
const { data: news } = await axios.get<NewsTileType[]>('/api/news');
|
||||||
);
|
setNews(news);
|
||||||
|
} catch (error) {
|
||||||
setNews(news);
|
if (axios.isAxiosError(error)) {
|
||||||
|
console.error(
|
||||||
|
'Failed to fetch news:',
|
||||||
|
error.response?.data || error.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch news:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!news) {
|
if (!news) {
|
||||||
|
|||||||
@@ -1,8 +1,46 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
app:
|
postgres:
|
||||||
build:
|
image: postgres:16-alpine
|
||||||
context: .
|
container_name: newsletter-db
|
||||||
dockerfile: Dockerfile
|
environment:
|
||||||
|
- POSTGRES_USER=newsletter
|
||||||
|
- POSTGRES_PASSWORD=newsletter
|
||||||
|
- POSTGRES_DB=newsletter
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U newsletter"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Postfix mail server with OpenDKIM for self-hosted email
|
||||||
|
postfix:
|
||||||
|
build: ./docker/postfix
|
||||||
|
container_name: newsletter-postfix
|
||||||
|
restart: always
|
||||||
|
# dns: Uses VPS default DNS. If MX lookups fail, uncomment and set explicit DNS:
|
||||||
|
# - 213.186.33.99 # OVH DNS
|
||||||
|
environment:
|
||||||
|
- MAIL_DOMAIN=${MAIL_DOMAIN:-example.com}
|
||||||
|
- MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.example.com}
|
||||||
|
- DKIM_SELECTOR=${DKIM_SELECTOR:-mail}
|
||||||
|
volumes:
|
||||||
|
# Persist DKIM keys across container rebuilds
|
||||||
|
- postfix-dkim:/etc/opendkim/keys
|
||||||
|
# Persist mail queue
|
||||||
|
- postfix-spool:/var/spool/postfix
|
||||||
|
# Port 25 not exposed to host - only accessible within Docker network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "echo 'QUIT' | nc -w 5 localhost 25 | grep -q '220' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
postfix-dkim:
|
||||||
|
postfix-spool:
|
||||||
|
|||||||
30
docker/postfix/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# install oostfix and OpenDKIM
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postfix \
|
||||||
|
opendkim \
|
||||||
|
opendkim-tools \
|
||||||
|
mailutils \
|
||||||
|
ca-certificates \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# create OpenDKIM paths
|
||||||
|
RUN mkdir -p /etc/opendkim/keys && \
|
||||||
|
chown -R opendkim:opendkim /etc/opendkim && \
|
||||||
|
chmod 700 /etc/opendkim/keys
|
||||||
|
|
||||||
|
# copy config
|
||||||
|
COPY postfix-main.cf /etc/postfix/main.cf
|
||||||
|
COPY opendkim.conf /etc/opendkim.conf
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 25
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD echo "QUIT" | nc -w 5 localhost 25 | grep -q "220" || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
90
docker/postfix/entrypoint.sh
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
MAIL_DOMAIN="${MAIL_DOMAIN:-example.com}"
|
||||||
|
MAIL_HOSTNAME="${MAIL_HOSTNAME:-mail.example.com}"
|
||||||
|
DKIM_SELECTOR="${DKIM_SELECTOR:-mail}"
|
||||||
|
|
||||||
|
echo "Setting up postfix for domain: ${MAIL_DOMAIN}"
|
||||||
|
echo "Hostname: ${MAIL_HOSTNAME}"
|
||||||
|
|
||||||
|
# configure postfix domain
|
||||||
|
postconf -e "myhostname=${MAIL_HOSTNAME}"
|
||||||
|
postconf -e "mydomain=${MAIL_DOMAIN}"
|
||||||
|
postconf -e "myorigin=\$mydomain"
|
||||||
|
postconf -e "mydestination=\$myhostname, localhost.\$mydomain, localhost"
|
||||||
|
|
||||||
|
# create OpenDKIM key folder for domain
|
||||||
|
DKIM_KEY_DIR="/etc/opendkim/keys/${MAIL_DOMAIN}"
|
||||||
|
mkdir -p "${DKIM_KEY_DIR}"
|
||||||
|
|
||||||
|
# generate DKIM keys if they don't exist
|
||||||
|
if [ ! -f "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private" ]; then
|
||||||
|
echo "Generating DKIM keys for ${MAIL_DOMAIN}..."
|
||||||
|
opendkim-genkey -b 2048 -d "${MAIL_DOMAIN}" -D "${DKIM_KEY_DIR}" -s "${DKIM_SELECTOR}" -v
|
||||||
|
chown -R opendkim:opendkim "${DKIM_KEY_DIR}"
|
||||||
|
chmod 600 "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo "DKIM PUBLIC KEY - ADD THIS TO YOUR DNS:"
|
||||||
|
echo "============================================"
|
||||||
|
echo "Record Type: TXT"
|
||||||
|
echo "Name: ${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN}"
|
||||||
|
echo ""
|
||||||
|
cat "${DKIM_KEY_DIR}/${DKIM_SELECTOR}.txt"
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "Using existing DKIM keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# configure OpenDKIM KeyTable
|
||||||
|
cat > /etc/opendkim/KeyTable << EOF
|
||||||
|
${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN} ${MAIL_DOMAIN}:${DKIM_SELECTOR}:${DKIM_KEY_DIR}/${DKIM_SELECTOR}.private
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# configure OpenDKIM SigningTable
|
||||||
|
cat > /etc/opendkim/SigningTable << EOF
|
||||||
|
*@${MAIL_DOMAIN} ${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# configure OpenDKIM TrustedHosts
|
||||||
|
cat > /etc/opendkim/TrustedHosts << EOF
|
||||||
|
127.0.0.1
|
||||||
|
localhost
|
||||||
|
${MAIL_DOMAIN}
|
||||||
|
*.${MAIL_DOMAIN}
|
||||||
|
172.16.0.0/12
|
||||||
|
192.168.0.0/16
|
||||||
|
10.0.0.0/8
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# set permissions
|
||||||
|
chown -R opendkim:opendkim /etc/opendkim
|
||||||
|
chmod 600 /etc/opendkim/KeyTable
|
||||||
|
chmod 600 /etc/opendkim/SigningTable
|
||||||
|
|
||||||
|
# create postfix spool folders
|
||||||
|
mkdir -p /var/spool/postfix/pid
|
||||||
|
chown root:root /var/spool/postfix
|
||||||
|
chown root:root /var/spool/postfix/pid
|
||||||
|
|
||||||
|
# start OpenDKIM in background
|
||||||
|
echo "Starting OpenDKIM..."
|
||||||
|
opendkim -f &
|
||||||
|
|
||||||
|
# wait for OpenDKIM to start
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# copy DNS config to postfix chroot
|
||||||
|
mkdir -p /var/spool/postfix/etc
|
||||||
|
cp /etc/resolv.conf /var/spool/postfix/etc/
|
||||||
|
cp /etc/services /var/spool/postfix/etc/
|
||||||
|
cp /etc/hosts /var/spool/postfix/etc/
|
||||||
|
|
||||||
|
# start postfix in foreground
|
||||||
|
echo "Starting Postfix..."
|
||||||
|
postfix start-fg
|
||||||
30
docker/postfix/opendkim.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# OpenDKIM configuration for email signing
|
||||||
|
|
||||||
|
# log to syslog
|
||||||
|
Syslog yes
|
||||||
|
SyslogSuccess yes
|
||||||
|
LogWhy yes
|
||||||
|
|
||||||
|
# required for verification
|
||||||
|
Canonicalization relaxed/simple
|
||||||
|
|
||||||
|
# sign mode (s=sign, v=verify)
|
||||||
|
Mode sv
|
||||||
|
|
||||||
|
# sign subdomains
|
||||||
|
SubDomains no
|
||||||
|
|
||||||
|
# key configuration (will be set by entrypoint)
|
||||||
|
KeyTable /etc/opendkim/KeyTable
|
||||||
|
SigningTable refile:/etc/opendkim/SigningTable
|
||||||
|
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
|
||||||
|
InternalHosts refile:/etc/opendkim/TrustedHosts
|
||||||
|
|
||||||
|
# socket for postfix connection
|
||||||
|
Socket inet:8891@localhost
|
||||||
|
|
||||||
|
# user
|
||||||
|
UserID opendkim
|
||||||
|
|
||||||
|
# permissions
|
||||||
|
RequireSafeKeys false
|
||||||
50
docker/postfix/postfix-main.cf
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Postfix main configuration for newsletter sending
|
||||||
|
# Domain and hostname will be set by entrypoint script
|
||||||
|
|
||||||
|
smtpd_banner = $myhostname ESMTP
|
||||||
|
biff = no
|
||||||
|
append_dot_mydomain = no
|
||||||
|
readme_directory = no
|
||||||
|
|
||||||
|
# TLS parameters (for outbound connections)
|
||||||
|
smtp_tls_security_level = may
|
||||||
|
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
|
||||||
|
|
||||||
|
# Network settings
|
||||||
|
inet_interfaces = all
|
||||||
|
inet_protocols = ipv4
|
||||||
|
|
||||||
|
# Relay settings (don't relay for others)
|
||||||
|
relayhost =
|
||||||
|
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8
|
||||||
|
|
||||||
|
# Message size limit (10MB)
|
||||||
|
message_size_limit = 10240000
|
||||||
|
|
||||||
|
# Queue settings
|
||||||
|
maximal_queue_lifetime = 5d
|
||||||
|
bounce_queue_lifetime = 5d
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
smtpd_helo_required = yes
|
||||||
|
disable_vrfy_command = yes
|
||||||
|
|
||||||
|
smtpd_helo_restrictions =
|
||||||
|
permit_mynetworks,
|
||||||
|
reject_invalid_helo_hostname,
|
||||||
|
reject_non_fqdn_helo_hostname,
|
||||||
|
permit
|
||||||
|
|
||||||
|
smtpd_recipient_restrictions =
|
||||||
|
permit_mynetworks,
|
||||||
|
reject_unauth_destination,
|
||||||
|
permit
|
||||||
|
|
||||||
|
# OpenDKIM integration
|
||||||
|
milter_default_action = accept
|
||||||
|
milter_protocol = 6
|
||||||
|
smtpd_milters = inet:localhost:8891
|
||||||
|
non_smtpd_milters = inet:localhost:8891
|
||||||
|
|
||||||
|
# Notify on bounces
|
||||||
|
notify_classes = bounce, delay, resource, software
|
||||||
11915
package-lock.json
generated
Normal file
10
package.json
@@ -21,31 +21,33 @@
|
|||||||
"prisma:reset": "npx prisma db push --force-reset"
|
"prisma:reset": "npx prisma db push --force-reset"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.27.3",
|
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@next/bundle-analyzer": "^14.2.5",
|
"@next/bundle-analyzer": "^14.2.5",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@vercel/analytics": "^1.1.1",
|
"@vercel/analytics": "^1.1.1",
|
||||||
|
"axios": "^1.12.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"isomorphic-dompurify": "^2.15.0",
|
"isomorphic-dompurify": "^2.15.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"next": "^14.2.10",
|
"next": "^15.5.9",
|
||||||
|
"nodemailer": "^7.0.12",
|
||||||
|
"openai": "^4.77.0",
|
||||||
"postcss-nesting": "^12.0.2",
|
"postcss-nesting": "^12.0.2",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"resend": "^3.1.0",
|
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^18.4.3",
|
"@commitlint/cli": "^18.4.3",
|
||||||
"@commitlint/config-conventional": "^18.4.3",
|
"@commitlint/config-conventional": "^18.4.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^7.0.5",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `resendId` on the `users` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" DROP COLUMN "resendId";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Rename the column without dropping it
|
||||||
|
ALTER TABLE news RENAME COLUMN created_at TO "createdAt";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "email_logs" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"recipient" TEXT NOT NULL,
|
||||||
|
"subject" TEXT,
|
||||||
|
"message_id" TEXT,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"error_message" TEXT,
|
||||||
|
"bounce_type" TEXT,
|
||||||
|
"bounce_details" JSONB,
|
||||||
|
|
||||||
|
CONSTRAINT "email_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "email_logs_recipient_idx" ON "email_logs"("recipient");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "email_logs_status_idx" ON "email_logs"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "email_logs_sent_at_idx" ON "email_logs"("sent_at");
|
||||||
@@ -3,19 +3,17 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resendId String
|
email String @unique
|
||||||
email String @unique
|
code String @unique
|
||||||
code String @unique
|
confirmed Boolean @default(false)
|
||||||
confirmed Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
deleted Boolean @default(false)
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
|
||||||
lastMail DateTime?
|
lastMail DateTime?
|
||||||
|
|
||||||
@@map(name: "users")
|
@@map(name: "users")
|
||||||
@@ -30,7 +28,24 @@ model News {
|
|||||||
time Float
|
time Float
|
||||||
url String?
|
url String?
|
||||||
score Float
|
score Float
|
||||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map(name: "news")
|
@@map(name: "news")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EmailLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
recipient String
|
||||||
|
subject String?
|
||||||
|
messageId String? @map("message_id")
|
||||||
|
status String // 'sent', 'failed', 'bounced'
|
||||||
|
sentAt DateTime @default(now()) @map("sent_at")
|
||||||
|
errorMessage String? @map("error_message")
|
||||||
|
bounceType String? @map("bounce_type")
|
||||||
|
bounceDetails Json? @map("bounce_details")
|
||||||
|
|
||||||
|
@@index([recipient])
|
||||||
|
@@index([status])
|
||||||
|
@@index([sentAt])
|
||||||
|
@@map(name: "email_logs")
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/email-icons/building-2.png
Normal file
|
After Width: | Height: | Size: 397 B |
BIN
public/email-icons/house.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
public/email-icons/layout-grid.png
Normal file
|
After Width: | Height: | Size: 305 B |
BIN
public/email-icons/log-out.png
Normal file
|
After Width: | Height: | Size: 328 B |
BIN
public/email-icons/mail.png
Normal file
|
After Width: | Height: | Size: 414 B |
BIN
public/email-icons/shield.png
Normal file
|
After Width: | Height: | Size: 480 B |
BIN
public/email-icons/user.png
Normal file
|
After Width: | Height: | Size: 394 B |
103
utils/ai/client.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import { BaseTool } from './tool';
|
||||||
|
|
||||||
|
let ovhAI: OpenAI | null = null;
|
||||||
|
|
||||||
|
function getClient(): OpenAI {
|
||||||
|
if (!ovhAI) {
|
||||||
|
ovhAI = new OpenAI({
|
||||||
|
apiKey: process.env.OVHCLOUD_API_KEY,
|
||||||
|
baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ovhAI;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export async function getMessage<T>(
|
||||||
|
text: string,
|
||||||
|
baseTool: BaseTool
|
||||||
|
): Promise<T> {
|
||||||
|
const requiredFields = baseTool.input_schema.required
|
||||||
|
? [...baseTool.input_schema.required]
|
||||||
|
: Object.keys(baseTool.input_schema.properties);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
console.info(`OVH AI request attempt ${attempt}/${MAX_RETRIES}`);
|
||||||
|
|
||||||
|
const completion = await getClient().chat.completions.create({
|
||||||
|
model: 'Meta-Llama-3_3-70B-Instruct',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 16000,
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: baseTool.name,
|
||||||
|
description: baseTool.input_schema.description || '',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: baseTool.input_schema.properties,
|
||||||
|
required: requiredFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tool_choice: {
|
||||||
|
type: 'function',
|
||||||
|
function: { name: baseTool.name }
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a professional tech journalist creating newsletter content.
|
||||||
|
|
||||||
|
CRITICAL REQUIREMENTS:
|
||||||
|
1. You MUST call the function with ALL required fields populated
|
||||||
|
2. Required fields: ${requiredFields.join(', ')}
|
||||||
|
3. Every field must be fully populated with high-quality content
|
||||||
|
4. Do NOT leave any field as null, undefined, or empty`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = completion.choices[0]?.message;
|
||||||
|
|
||||||
|
if (!message?.tool_calls || message.tool_calls.length === 0) {
|
||||||
|
throw new Error('No function call found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = message.tool_calls[0];
|
||||||
|
|
||||||
|
if (toolCall.function.name !== baseTool.name) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected tool ${baseTool.name} but got ${toolCall.function.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(toolCall.function.arguments);
|
||||||
|
console.info('OVH AI response:', result);
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Attempt ${attempt} failed:`, error);
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
const delay = 1000 * attempt;
|
||||||
|
console.info(`Retrying in ${delay}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('All retry attempts failed');
|
||||||
|
throw lastError || new Error('OVH AI Endpoints client error.');
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ export interface BaseTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NewsletterTool {
|
export interface NewsletterTool {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -18,20 +25,25 @@ export const newsletterTool: BaseTool = {
|
|||||||
name: 'NewsletterTool' as const,
|
name: 'NewsletterTool' as const,
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
description: 'Newsletter',
|
description:
|
||||||
|
'Generate a tech newsletter with title, content, and focus sections',
|
||||||
properties: {
|
properties: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The title of the newsletter'
|
description:
|
||||||
|
'The title of the newsletter (40-50 characters, capturing key themes)'
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The main content of the newsletter'
|
description:
|
||||||
|
'The main content of the newsletter (300-400 words, HTML formatted with paragraph tags and links)'
|
||||||
},
|
},
|
||||||
focus: {
|
focus: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The text of the focus segment'
|
description:
|
||||||
|
'Forward-looking assessment of key developments and trends'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
required: ['title', 'content', 'focus'] as const
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import Anthropic from '@anthropic-ai/sdk';
|
|
||||||
import { BaseTool } from './tool';
|
|
||||||
|
|
||||||
export async function getMessage<T>(text: string, tool: BaseTool) {
|
|
||||||
const anthropic = new Anthropic({
|
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info('Anthropic request with text: ', text);
|
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
|
||||||
model: 'claude-3-5-sonnet-20241022',
|
|
||||||
max_tokens: 2048,
|
|
||||||
messages: [{ role: 'user', content: text }],
|
|
||||||
tools: [tool]
|
|
||||||
});
|
|
||||||
|
|
||||||
console.info('Anthropic response: ', response);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = response.content as [
|
|
||||||
{ type: string; text: string },
|
|
||||||
{ type: string; input: object } // object of type V
|
|
||||||
];
|
|
||||||
|
|
||||||
return data[1].input as T;
|
|
||||||
} catch (error) {
|
|
||||||
throw Error(JSON.stringify(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
213
utils/mailer.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import prisma from '@prisma/prisma';
|
||||||
|
|
||||||
|
interface EmailTemplate {
|
||||||
|
subject: string;
|
||||||
|
template: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendResult {
|
||||||
|
success: boolean;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create nodemailer transporter for Postfix container
|
||||||
|
const createTransporter = () => {
|
||||||
|
// Default to 'postfix' for Docker networking (service name in docker-compose)
|
||||||
|
// Override with EMAIL_HOST for different setups
|
||||||
|
const host = process.env.EMAIL_HOST || 'postfix';
|
||||||
|
const port = parseInt(process.env.EMAIL_PORT || '25', 10);
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: false, // true for 465, false for other ports
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
},
|
||||||
|
// Connection pooling for better performance
|
||||||
|
pool: true,
|
||||||
|
maxConnections: 5,
|
||||||
|
maxMessages: 100
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render React component to HTML string using dynamic import
|
||||||
|
const renderTemplate = async (template: JSX.Element): Promise<string> => {
|
||||||
|
// Dynamic import to avoid Next.js bundling issues with react-dom/server
|
||||||
|
const { renderToStaticMarkup } = await import('react-dom/server');
|
||||||
|
const html = renderToStaticMarkup(template);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
|
||||||
|
${html}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate plain text version from HTML (basic extraction)
|
||||||
|
const htmlToPlainText = (html: string): string => {
|
||||||
|
return html
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log email send attempt to database
|
||||||
|
const logEmailSend = async (
|
||||||
|
recipient: string,
|
||||||
|
subject: string,
|
||||||
|
status: 'sent' | 'failed',
|
||||||
|
messageId?: string,
|
||||||
|
errorMessage?: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await prisma.emailLog.create({
|
||||||
|
data: {
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
status,
|
||||||
|
messageId,
|
||||||
|
errorMessage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log email send:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send a single email
|
||||||
|
const sendSingleEmail = async (
|
||||||
|
transporter: nodemailer.Transporter,
|
||||||
|
recipient: string,
|
||||||
|
subject: string,
|
||||||
|
htmlContent: string,
|
||||||
|
textContent: string
|
||||||
|
): Promise<SendResult> => {
|
||||||
|
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
|
||||||
|
const fromName = process.env.NEXT_PUBLIC_BRAND_NAME || 'HackerNews Newsletter';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${fromName}" <${fromAddress}>`,
|
||||||
|
to: recipient,
|
||||||
|
subject,
|
||||||
|
text: textContent,
|
||||||
|
html: htmlContent,
|
||||||
|
headers: {
|
||||||
|
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`,
|
||||||
|
'X-Newsletter-ID': `hackernews-${Date.now()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await logEmailSend(recipient, subject, 'sent', info.messageId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: info.messageId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
await logEmailSend(recipient, subject, 'failed', undefined, errorMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main sender function - maintains same API as Resend version
|
||||||
|
export async function sender(
|
||||||
|
recipients: string[],
|
||||||
|
{ subject, template }: EmailTemplate
|
||||||
|
): Promise<boolean> {
|
||||||
|
const fromAddress = process.env.EMAIL_FROM || process.env.RESEND_FROM;
|
||||||
|
|
||||||
|
if (!fromAddress) {
|
||||||
|
throw new Error('EMAIL_FROM or RESEND_FROM environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
console.info(`${subject} email skipped for having zero recipients`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = createTransporter();
|
||||||
|
|
||||||
|
// Render template to HTML
|
||||||
|
const htmlContent = await renderTemplate(template);
|
||||||
|
const textContent = htmlToPlainText(htmlContent);
|
||||||
|
|
||||||
|
// Add small delay between sends to avoid overwhelming mail server
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const errors: Array<{ email: string; error: string }> = [];
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const result = await sendSingleEmail(
|
||||||
|
transporter,
|
||||||
|
recipient,
|
||||||
|
subject,
|
||||||
|
htmlContent,
|
||||||
|
textContent
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
errors.push({ email: recipient, error: result.error || 'Unknown error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 100ms delay between sends
|
||||||
|
if (recipients.length > 1) {
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the transporter pool
|
||||||
|
transporter.close();
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error('Email send errors:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`${subject} email: ${successCount} sent, ${failCount} failed out of ${recipients.length} recipients`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return true if at least one email was sent successfully
|
||||||
|
return successCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify transporter connection (useful for health checks)
|
||||||
|
export async function verifyMailer(): Promise<boolean> {
|
||||||
|
const transporter = createTransporter();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.verify();
|
||||||
|
console.log('Email server connection verified');
|
||||||
|
transporter.close();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email server connection failed:', error);
|
||||||
|
transporter.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
interface EmailTemplate {
|
|
||||||
subject: string;
|
|
||||||
template: JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sender(
|
|
||||||
recipients: string[],
|
|
||||||
{ subject, template }: EmailTemplate
|
|
||||||
) {
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
console.info(`${subject} email skipped for having zero recipients`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_KEY);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (recipients.length == 1) {
|
|
||||||
response = await resend.emails.send({
|
|
||||||
from: process.env.RESEND_FROM!,
|
|
||||||
to: recipients[0],
|
|
||||||
subject,
|
|
||||||
react: template,
|
|
||||||
headers: {
|
|
||||||
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await resend.batch.send(
|
|
||||||
recipients.map(recipient => {
|
|
||||||
return {
|
|
||||||
from: process.env.RESEND_FROM!,
|
|
||||||
to: recipient,
|
|
||||||
subject,
|
|
||||||
react: template,
|
|
||||||
headers: {
|
|
||||||
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = response;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`${subject} email sent to ${recipients.length} recipients`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||