24 Commits

Author SHA1 Message Date
eeed88eba4 feat: self-hosted postfix 2026-01-24 08:43:50 +01:00
e46cb018fd feat: replace icons and obfuscate email
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m18s
2026-01-22 18:36:52 +01:00
a8d3bf1b3b feat: migrate AI to llama and use local db
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m13s
2026-01-19 21:25:38 +01:00
dependabot[bot]
6e75be19ed build(deps): bump next from 15.5.7 to 15.5.9 in the npm_and_yarn group across 1 directory (#41)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 22:50:18 +01:00
dependabot[bot]
f7e9d5c494 build(deps): bump next from 15.5.4 to 15.5.7 in the npm_and_yarn group across 1 directory (#40)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 15:14:10 +01:00
dependabot[bot]
c0caedd6fa build(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory (#39) 2025-11-16 20:48:22 +01:00
2afe8e39b9 feat: use vercel ai gateway (#38) 2025-10-11 16:37:36 +02:00
dependabot[bot]
8ed813dd53 build(deps): bump axios from 1.8.2 to 1.12.0 in the npm_and_yarn group across 1 directory (#37)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-28 17:16:54 +08:00
905054e3b5 fix: update claude version with response handling correction 2025-09-28 17:02:33 +08:00
271fa45937 fix: revert claude version 2025-09-28 16:53:18 +08:00
a38ddc727f chore: update claude version 2025-09-27 21:01:22 +08:00
dependabot[bot]
72019caca2 build(deps): bump next from 15.2.4 to 15.4.7 in the npm_and_yarn group across 1 directory (#36)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-14 20:44:32 +08:00
dependabot[bot]
449277be21 build(deps): bump form-data from 4.0.1 to 4.0.4 in the npm_and_yarn group across 1 directory (#35)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-04 20:22:10 +08:00
dependabot[bot]
77211d58cc build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#34)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 22:29:41 +02:00
dependabot[bot]
5673870649 build(deps): bump dompurify from 3.2.3 to 3.2.6 in the npm_and_yarn group across 1 directory (#33)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 22:26:07 +02:00
Riccardo Senica
f11299ed93 docs: update README.md 2025-01-18 16:18:53 +01:00
11c26df116 docs: update README.md 2025-01-18 12:41:26 +01:00
Riccardo Senica
2752045c16 chore: rename new created_at column (#31) 2025-01-18 11:48:40 +01:00
Riccardo Senica
2616728128 chore: resend cleaning (#28) 2025-01-07 20:12:24 +01:00
Riccardo Senica
a8de784981 chore: remove import email (#27) 2025-01-07 20:06:06 +01:00
867e6e65a5 chore: remove unused env variables 2024-12-30 07:10:18 +01:00
e39026c259 chore: some refactor and cleaning 2024-12-30 06:54:01 +01:00
dependabot[bot]
5c75e9390e build(deps): bump next from 14.2.13 to 14.2.15 in the npm_and_yarn group (#24)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-21 15:51:07 +01:00
dependabot[bot]
cc71d9be21 build(deps): bump nanoid from 3.3.7 to 3.3.8 in the npm_and_yarn group (#23)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-21 15:42:15 +01:00
42 changed files with 19030 additions and 7422 deletions

View File

@@ -1,39 +1,19 @@
ADMIN_EMAIL=""
CRON_SECRET=""
HOME_URL=""
MAINTENANCE_MODE=""
NEWS_LIMIT=""
OVHCLOUD_API_KEY=""
MAINTENANCE_MODE="0"
NEWS_LIMIT="50"
NEWS_TO_USE="10"
NEXT_PUBLIC_BRAND_COUNTRY=""
NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_NAME=""
NX_DAEMON=""
POSTGRES_DATABASE=""
POSTGRES_HOST=""
POSTGRES_PASSWORD=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER=""
RESEND_AUDIENCE=""
RESEND_FROM=""
RESEND_KEY=""
DATABASE_URL=""
SECRET_HASH=""
TURBO_REMOTE_ONLY=""
TURBO_RUN_SUMMARY=""
VERCEL="1"
VERCEL_ENV="development"
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
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
EMAIL_HOST="postfix"
EMAIL_PORT="25"
EMAIL_FROM=""
MAIL_DOMAIN=""
MAIL_HOSTNAME=""
DKIM_SELECTOR="mail"

View 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
View 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

View File

@@ -4,12 +4,20 @@ WORKDIR /app
COPY package*.json ./
RUN yarn
RUN npm install
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
CMD [ "yarn", "start" ]
CMD [ "npm", "start" ]

View File

@@ -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/
https://codepen.io/alphardex/pen/vYEYGzp
## 🚀 Future improvements
## 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
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
```
Link to vercel
```bash
# Link to your Vercel project
yarn vercel:link
```
Pull env variables from vercel
```bash
# Pull environment variables
yarn vercel:env
```
Push Prisma schema to vercel
4. Set up the database:
```bash
# Push Prisma schema to database
yarn db:push
```
Generate Prisma client
```bash
# Generate Prisma client
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
yarn db:reset
```
Run on Docker
## 🙏 Acknowledgments
```bash
docker-compose up --build
```
- 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/)
- ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp)

View File

@@ -9,13 +9,12 @@ import {
} from '@utils/statusCodes';
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: NextRequest) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
if (!process.env.RESEND_KEY) {
throw new Error('Resend variables not set');
}
const body = await request.json();
@@ -31,14 +30,6 @@ export async function POST(request: NextRequest) {
});
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({
where: {
code: validation.data.code

View File

@@ -1,4 +1,5 @@
import prisma from '@prisma/prisma';
import axios from 'axios';
import { formatApiResponse } from '@utils/formatApiResponse';
import {
INTERNAL_SERVER_ERROR,
@@ -9,7 +10,6 @@ import {
import { getSingleNews, getTopNews } from '@utils/urls';
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export async function GET(request: NextRequest) {
if (
@@ -19,17 +19,22 @@ export async function GET(request: NextRequest) {
}
try {
const topStories: number[] = await fetch(getTopNews, {
cache: 'no-store'
}).then(res => res.json());
const { data: topStories } = await axios.get<number[]>(getTopNews, {
headers: {
'Cache-Control': 'no-store'
}
});
console.info(`Top stories ids: ${topStories}`);
const newsPromises = topStories
.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 validation = NewsDatabaseSchema.safeParse(getSingleNews);
@@ -65,23 +70,16 @@ export async function GET(request: NextRequest) {
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(
STATUS_OK,
`Imported ${newsPromises.length} news.`
);
} 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(
STATUS_INTERNAL_SERVER_ERROR,
INTERNAL_SERVER_ERROR

View File

@@ -1,7 +1,7 @@
import { NewsletterTemplate } from '@components/email/Newsletter';
import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient';
import { sender } from '@utils/mailer';
import {
INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR,
@@ -25,9 +25,6 @@ export async function GET(request: NextRequest) {
}
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({
where: {
confirmed: true,

View File

@@ -1,7 +1,7 @@
import { ConfirmationTemplate } from '@components/email/Confirmation';
import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient';
import { sender } from '@utils/mailer';
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
@@ -12,14 +12,9 @@ import {
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
import * as crypto from 'crypto';
import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export async function POST(request: NextRequest) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_KEY is not set');
}
const body = await request.json();
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
.createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`)
@@ -53,19 +46,6 @@ export async function POST(request: NextRequest) {
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 = {
@@ -84,21 +64,10 @@ export async function POST(request: NextRequest) {
}
});
} 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({
data: {
email,
code,
resendId: contact.data.id
code
}
});
}

View File

@@ -1,7 +1,7 @@
import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient';
import { sender } from '@utils/mailer';
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
@@ -11,17 +11,13 @@ import {
} from '@utils/statusCodes';
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: NextRequest) {
try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json();
const validation = UnsubscribeFormSchema.safeParse(body);
if (!validation.success) {
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());
if (!sent) {

View File

@@ -4,6 +4,7 @@ import { CardDescription } from '@components/Card';
import { CustomCard } from '@components/CustomCard';
import { SchemaOrg } from '@components/SchemaOrg';
import { ResponseType } from '@utils/validationSchemas';
import axios from 'axios';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -23,32 +24,31 @@ const ConfirmationPage = () => {
}
try {
const res = await fetch('/api/confirmation', {
method: 'POST',
const { data } = await axios.post<ResponseType>(
'/api/confirmation',
{
code: code
},
{
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code: code
})
});
}
}
);
if (!res.ok) {
if (!data.success) {
router.push('/');
return;
}
const response: ResponseType = await res.json();
if (!response.success) {
router.push('/');
return;
}
setMessage(response.message);
setMessage(data.message);
setLoading(false);
} 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('/');
}
};

View File

@@ -18,6 +18,7 @@ import {
} from '@utils/validationSchemas';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import axios from 'axios';
export const Home = () => {
const [completed, setCompleted] = useState(false);
@@ -45,29 +46,28 @@ export const Home = () => {
async function handleSubmit(values: SubscribeFormType) {
setIsLoading(true);
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
const { data } = await axios.post<ResponseType>(
'/api/subscribe',
{
email: values.email
},
{
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.email
})
});
}
}
);
if (!response?.ok) {
throw new Error(`Invalid response: ${response.status}`);
if (!data.success) {
throw new Error(data.message);
}
const formResponse: ResponseType = await response.json();
if (!formResponse.success) {
throw Error(formResponse.message);
}
setMessage(formResponse.message);
setMessage(data.message);
setCompleted(true);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
}
setError(true);
} finally {
setIsLoading(false);

View File

@@ -2,8 +2,21 @@
import { CustomCard } from '@components/CustomCard';
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 email = useObfuscatedEmail();
const schema = {
'@context': 'https://schema.org',
'@type': 'WebSite',
@@ -431,12 +444,16 @@ const Privacy = () => {
<p className='leading-relaxed'>
If you have any questions about this Privacy Policy, You can contact us
by writing to{' '}
{email ? (
<a
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
href={`mailto:${email}`}
className='text-purple-600 hover:text-purple-700'
>
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
{email}
</a>
) : (
<span className='text-gray-400'>loading...</span>
)}
.
</p>
</div>

View File

@@ -16,6 +16,8 @@ import {
UnsubscribeFormSchema,
UnsubscribeFormType
} from '@utils/validationSchemas';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
@@ -52,29 +54,28 @@ const Unsubscribe = () => {
async function handleSubmit(values: UnsubscribeFormType) {
setIsLoading(true);
try {
const response = await fetch('/api/unsubscribe', {
method: 'POST',
const { data } = await axios.post<ResponseType>(
'/api/unsubscribe',
{
email: values.email
},
{
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.email
})
});
}
}
);
if (!response?.ok) {
throw new Error(`Invalid response: ${response.status}`);
if (!data.success) {
throw new Error(data.message);
}
const formResponse: ResponseType = await response.json();
if (!formResponse.success) {
throw Error(formResponse.message);
}
setMessage(formResponse.message);
setMessage(data.message);
setCompleted(true);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
}
setError(true);
} finally {
setIsLoading(false);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { summirize } from '@utils/anthropic/summarize';
import { summirize } from '@utils/ai/summarize';
import { NewsType } from '@utils/validationSchemas';
import createDOMPurify from 'isomorphic-dompurify';
import { Template } from './Template';

View File

@@ -1,12 +1,17 @@
import {
User,
Building2,
Mail,
LogOut,
LayoutGrid,
Shield,
Home
} from 'lucide-react';
const iconStyle = {
display: 'inline-block',
verticalAlign: 'middle'
};
const Icon = ({ name, size = 16 }: { name: string; size?: number }) => (
<img
src={`${process.env.HOME_URL}/email-icons/${name}.png`}
width={size}
height={size}
alt=""
style={iconStyle}
/>
);
export const Footer = () => {
return (
@@ -44,7 +49,7 @@ export const Footer = () => {
letterSpacing: '0.05em'
}}
>
<User size={16} color='#386FA4' />
<Icon name="user" size={16} />
Contact Us
</h4>
<p
@@ -57,7 +62,7 @@ export const Footer = () => {
color: '#4A5568'
}}
>
<Building2 size={14} color='#386FA4' />
<Icon name="building-2" size={14} />
{process.env.NEXT_PUBLIC_BRAND_NAME}
</p>
<p
@@ -70,7 +75,7 @@ export const Footer = () => {
color: '#4A5568'
}}
>
<Mail size={14} color='#386FA4' />
<Icon name="mail" size={14} />
<a
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
style={{ color: '#386FA4', textDecoration: 'none' }}
@@ -88,7 +93,7 @@ export const Footer = () => {
color: '#4A5568'
}}
>
<LogOut size={14} color='#386FA4' />
<Icon name="log-out" size={14} />
<span>
Click{' '}
<a
@@ -124,7 +129,7 @@ export const Footer = () => {
letterSpacing: '0.05em'
}}
>
<LayoutGrid size={16} color='#386FA4' />
<Icon name="layout-grid" size={16} />
Quick Links
</h4>
<p
@@ -137,7 +142,7 @@ export const Footer = () => {
color: '#4A5568'
}}
>
<Shield size={14} color='#386FA4' />
<Icon name="shield" size={14} />
<a
href={`${process.env.HOME_URL}/privacy`}
style={{ color: '#386FA4', textDecoration: 'none' }}
@@ -155,7 +160,7 @@ export const Footer = () => {
color: '#4A5568'
}}
>
<Home size={14} color='#386FA4' />
<Icon name="house" size={14} />
<a
href={process.env.HOME_URL}
style={{ color: '#386FA4', textDecoration: 'none' }}

View File

@@ -4,6 +4,7 @@ import { NewsTileType } from '@utils/validationSchemas';
import { usePathname } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { Tile } from './components/Tile';
import axios from 'axios';
interface TilesProps {
children: React.ReactNode;
@@ -22,11 +23,19 @@ export const Tiles = ({ children }: TilesProps) => {
useEffect(() => {
async function getNews() {
const news: NewsTileType[] = await fetch('/api/news').then(res =>
res.json()
);
try {
const { data: news } = await axios.get<NewsTileType[]>('/api/news');
setNews(news);
} catch (error) {
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) {

View File

@@ -1,8 +1,46 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
postgres:
image: postgres:16-alpine
container_name: newsletter-db
environment:
- POSTGRES_USER=newsletter
- POSTGRES_PASSWORD=newsletter
- POSTGRES_DB=newsletter
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
View 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"]

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -21,31 +21,33 @@
"prisma:reset": "npx prisma db push --force-reset"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@hookform/resolvers": "^3.3.2",
"@next/bundle-analyzer": "^14.2.5",
"@prisma/client": "^5.6.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@vercel/analytics": "^1.1.1",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"isomorphic-dompurify": "^2.15.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",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.48.2",
"resend": "^3.1.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@types/node": "^20",
"@types/nodemailer": "^7.0.5",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^6.12.0",

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
-- Rename the column without dropping it
ALTER TABLE news RENAME COLUMN created_at TO "createdAt";

View File

@@ -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");

View File

@@ -4,13 +4,11 @@ generator client {
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
resendId String
email String @unique
code String @unique
confirmed Boolean @default(false)
@@ -30,7 +28,24 @@ model News {
time Float
url String?
score Float
createdAt DateTime @default(now()) @map(name: "created_at")
createdAt DateTime @default(now())
@@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")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

BIN
public/email-icons/mail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

BIN
public/email-icons/user.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

103
utils/ai/client.ts Normal file
View 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.');
}

View File

@@ -8,6 +8,13 @@ export interface BaseTool {
};
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: Record<string, unknown>;
}
export interface NewsletterTool {
title: string;
content: string;
@@ -18,20 +25,25 @@ export const newsletterTool: BaseTool = {
name: 'NewsletterTool' as const,
input_schema: {
type: 'object' as const,
description: 'Newsletter',
description:
'Generate a tech newsletter with title, content, and focus sections',
properties: {
title: {
type: 'string' as const,
description: 'The title of the newsletter'
description:
'The title of the newsletter (40-50 characters, capturing key themes)'
},
content: {
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: {
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;

View File

@@ -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
View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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;
}
}

View File

@@ -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;
}
}

13213
yarn.lock

File diff suppressed because it is too large Load Diff