Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8427d348dd | |||
| 7109b3c32e | |||
| 18717e7faf | |||
| 3478f0f42f | |||
| 836cc66334 | |||
| 9bb2186f7a | |||
| 55123132f4 | |||
| bf1dfe4015 | |||
| f03e417eb7 | |||
| e0312aeba9 | |||
| d5c0b91c97 | |||
| 155918e265 | |||
| fb5b55f1dd | |||
| 9f00828b80 | |||
| bb0debe29d | |||
| f2e855fff9 |
@@ -74,6 +74,7 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
|
||||
# Sentry / Glitchtip (Error Tracking)
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Gotify (In-App Notifications)
|
||||
# GOTIFY_URL=
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Build & Deploy
|
||||
# Infrastructure Maintenance: Production stabilization and email adapter fix triggered on 2026-04-12.
|
||||
name: 🚀 Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -36,13 +37,6 @@ jobs:
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -187,6 +181,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐳 Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
@@ -200,6 +196,8 @@ jobs:
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache
|
||||
@@ -223,7 +221,7 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
|
||||
# Secrets mapping (Database & CMS)
|
||||
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.MINTEL_PRIVATE_TOKEN || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
DATABASE_URI: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DATABASE_URI) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DATABASE_URI) || secrets.DATABASE_URI || vars.DATABASE_URI }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB || vars.POSTGRES_DB || 'payload' }}
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER || vars.POSTGRES_USER || 'directus' }}
|
||||
@@ -275,12 +273,14 @@ jobs:
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
# Generate Environment File
|
||||
echo "🔍 Verifying Sentry DSN presence..."
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
NEXT_PUBLIC_SENTRY_DSN=$SENTRY_DSN
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ConfirmationMessage,
|
||||
} from "@mintel/mail";
|
||||
import React from "react";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const services = getServerAppServices();
|
||||
@@ -77,11 +78,33 @@ export async function POST(req: Request) {
|
||||
services.errors.captureException(payloadError, { phase: "payload_save" });
|
||||
}
|
||||
|
||||
// 2. Email sending via Payload (which uses configured nodemailer)
|
||||
// 2. Email sending via standalone Nodemailer (bypassing Payload adapter)
|
||||
try {
|
||||
const { config } = await import("@/lib/config");
|
||||
const clientName = "MB Grid Solutions";
|
||||
|
||||
// Robust recipient resolution
|
||||
const recipients = Array.isArray(config.mail.recipients)
|
||||
? config.mail.recipients.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const to =
|
||||
recipients.length > 0
|
||||
? recipients.join(",")
|
||||
: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com";
|
||||
|
||||
logger.info("Instantiating standalone nodemailer transport");
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
auth: {
|
||||
user: config.mail.user,
|
||||
pass: config.mail.pass,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Preparing to send notification email", { to, host: config.mail.host });
|
||||
|
||||
// 2a. Notification to MB Grid
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
@@ -92,19 +115,23 @@ export async function POST(req: Request) {
|
||||
}),
|
||||
);
|
||||
|
||||
await payload.sendEmail({
|
||||
from: config.mail.from,
|
||||
to:
|
||||
config.mail.recipients.join(",") ||
|
||||
process.env.CONTACT_RECIPIENT ||
|
||||
"info@mb-grid-solutions.com",
|
||||
replyTo: email,
|
||||
subject: `Kontaktanfrage von ${name}`,
|
||||
html: notificationHtml,
|
||||
});
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: config.mail.from,
|
||||
to,
|
||||
replyTo: email,
|
||||
subject: `Kontaktanfrage von ${name}`,
|
||||
html: notificationHtml,
|
||||
});
|
||||
logger.info("Notification email sent successfully", { messageId: info?.messageId });
|
||||
} catch (notifyError) {
|
||||
logger.error("Failed to send notification email", { error: notifyError });
|
||||
throw notifyError; // Re-throw to be caught by the outer SMTP catch
|
||||
}
|
||||
|
||||
// 2b. Confirmation to the User
|
||||
try {
|
||||
logger.info("Preparing to send confirmation email", { to: email });
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
@@ -112,16 +139,17 @@ export async function POST(req: Request) {
|
||||
}),
|
||||
);
|
||||
|
||||
await payload.sendEmail({
|
||||
const info = await transporter.sendMail({
|
||||
from: config.mail.from,
|
||||
to: email,
|
||||
subject: `Ihre Kontaktanfrage bei ${clientName}`,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
logger.info("Confirmation email sent successfully", { messageId: info?.messageId });
|
||||
} catch (confirmError) {
|
||||
logger.warn(
|
||||
"Failed to send confirmation email, but notification was sent",
|
||||
{ error: confirmError },
|
||||
{ error: confirmError instanceof Error ? confirmError.message : String(confirmError) },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Public Router – paths that bypass Gatekeeper auth
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*|stats(/.*)?|errors(/.*)?)`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
|
||||
|
||||
10
instrumentation.ts
Normal file
10
instrumentation.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ function createConfig() {
|
||||
|
||||
errors: {
|
||||
glitchtip: {
|
||||
// Use SENTRY_DSN for both server and client (proxied)
|
||||
dsn: env.SENTRY_DSN,
|
||||
// Use NEXT_PUBLIC_SENTRY_DSN if available (required for browser-side SDK)
|
||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN || env.SENTRY_DSN,
|
||||
// The proxied origin used in the frontend
|
||||
proxyPath: "/errors",
|
||||
enabled: Boolean(env.SENTRY_DSN),
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
const envExtension = {
|
||||
// Project specific overrides or additions
|
||||
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
|
||||
export default createMiddleware({
|
||||
const intlMiddleware = createMiddleware({
|
||||
// A list of all locales that are supported
|
||||
locales: ["de"],
|
||||
|
||||
@@ -11,6 +13,17 @@ export default createMiddleware({
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Explicit bypass for analytics and error tracking paths
|
||||
if (pathname.startsWith("/stats") || pathname.startsWith("/errors")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Matcher for all pages and internationalized pathnames
|
||||
// excluding api, _next, static files, etc.
|
||||
|
||||
@@ -50,24 +50,29 @@ export default buildConfig({
|
||||
},
|
||||
prodMigrations: migrations,
|
||||
}),
|
||||
...(process.env.SMTP_HOST
|
||||
? {
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress:
|
||||
process.env.SMTP_FROM || "info@mb-grid-solutions.com",
|
||||
defaultFromName: "MB Grid Solutions CMS",
|
||||
transportOptions: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress:
|
||||
process.env.MAIL_FROM ||
|
||||
process.env.SMTP_FROM ||
|
||||
"info@mb-grid-solutions.com",
|
||||
defaultFromName: "MB Grid Solutions CMS",
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || process.env.SMTP_HOST || "localhost",
|
||||
port: parseInt(
|
||||
process.env.MAIL_PORT || process.env.SMTP_PORT || "587",
|
||||
),
|
||||
auth: {
|
||||
user:
|
||||
process.env.MAIL_USERNAME ||
|
||||
process.env.MAIL_USER ||
|
||||
process.env.SMTP_USER,
|
||||
pass: process.env.MAIL_PASSWORD || process.env.SMTP_PASS,
|
||||
},
|
||||
secure:
|
||||
process.env.MAIL_SECURE === "true" ||
|
||||
process.env.SMTP_SECURE === "true",
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
|
||||
@@ -6,6 +6,19 @@ const { mockCreate, mockSendEmail } = vi.hoisted(() => ({
|
||||
mockSendEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Nodemailer
|
||||
const { mockSendMail } = vi.hoisted(() => ({
|
||||
mockSendMail: vi.fn().mockResolvedValue({ messageId: "mock-message-id" }),
|
||||
}));
|
||||
|
||||
vi.mock("nodemailer", () => ({
|
||||
default: {
|
||||
createTransport: vi.fn().mockReturnValue({
|
||||
sendMail: mockSendMail,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
create: mockCreate,
|
||||
@@ -101,7 +114,7 @@ describe("Contact API Integration", () => {
|
||||
|
||||
// But it actually does NOTHING internally
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should successfully save to Payload and send emails", async () => {
|
||||
@@ -140,10 +153,10 @@ describe("Contact API Integration", () => {
|
||||
});
|
||||
|
||||
// 2. Verify Email Sending
|
||||
// Note: sendEmail is called twice (Notification + User Confirmation)
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(2);
|
||||
// Note: sendMail is called twice (Notification + User Confirmation)
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
||||
expect(mockSendMail).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
subject: "Kontaktanfrage von Jane Doe",
|
||||
@@ -151,7 +164,7 @@ describe("Contact API Integration", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
||||
expect(mockSendMail).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
to: "jane@example.com",
|
||||
|
||||
Reference in New Issue
Block a user