Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7109b3c32e | |||
| 18717e7faf | |||
| 3478f0f42f | |||
| 836cc66334 | |||
| 9bb2186f7a | |||
| 55123132f4 | |||
| bf1dfe4015 | |||
| f03e417eb7 | |||
| e0312aeba9 | |||
| d5c0b91c97 | |||
| 155918e265 | |||
| fb5b55f1dd |
@@ -1,4 +1,5 @@
|
|||||||
name: Build & Deploy
|
# Infrastructure Maintenance: Production stabilization and email adapter fix triggered on 2026-04-12.
|
||||||
|
name: 🚀 Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -36,13 +37,6 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -187,6 +181,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: 🐳 Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
@@ -200,6 +196,8 @@ jobs:
|
|||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
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 }}
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
|
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
|
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 }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
|
||||||
# Secrets mapping (Database & CMS)
|
# 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 }}
|
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_DB: ${{ secrets.POSTGRES_DB || vars.POSTGRES_DB || 'payload' }}
|
||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER || vars.POSTGRES_USER || 'directus' }}
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER || vars.POSTGRES_USER || 'directus' }}
|
||||||
@@ -275,6 +273,7 @@ jobs:
|
|||||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||||
|
|
||||||
# Generate Environment File
|
# Generate Environment File
|
||||||
|
echo "🔍 Verifying Sentry DSN presence..."
|
||||||
cat > .env.deploy << EOF
|
cat > .env.deploy << EOF
|
||||||
# Generated by CI - $TARGET
|
# Generated by CI - $TARGET
|
||||||
IMAGE_TAG=$IMAGE_TAG
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ConfirmationMessage,
|
ConfirmationMessage,
|
||||||
} from "@mintel/mail";
|
} from "@mintel/mail";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
@@ -77,11 +78,33 @@ export async function POST(req: Request) {
|
|||||||
services.errors.captureException(payloadError, { phase: "payload_save" });
|
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 {
|
try {
|
||||||
const { config } = await import("@/lib/config");
|
const { config } = await import("@/lib/config");
|
||||||
const clientName = "MB Grid Solutions";
|
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
|
// 2a. Notification to MB Grid
|
||||||
const notificationHtml = await render(
|
const notificationHtml = await render(
|
||||||
React.createElement(ContactFormNotification, {
|
React.createElement(ContactFormNotification, {
|
||||||
@@ -92,19 +115,23 @@ export async function POST(req: Request) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await payload.sendEmail({
|
try {
|
||||||
from: config.mail.from,
|
const info = await transporter.sendMail({
|
||||||
to:
|
from: config.mail.from,
|
||||||
config.mail.recipients.join(",") ||
|
to,
|
||||||
process.env.CONTACT_RECIPIENT ||
|
replyTo: email,
|
||||||
"info@mb-grid-solutions.com",
|
subject: `Kontaktanfrage von ${name}`,
|
||||||
replyTo: email,
|
html: notificationHtml,
|
||||||
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
|
// 2b. Confirmation to the User
|
||||||
try {
|
try {
|
||||||
|
logger.info("Preparing to send confirmation email", { to: email });
|
||||||
const confirmationHtml = await render(
|
const confirmationHtml = await render(
|
||||||
React.createElement(ConfirmationMessage, {
|
React.createElement(ConfirmationMessage, {
|
||||||
name,
|
name,
|
||||||
@@ -112,16 +139,17 @@ export async function POST(req: Request) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await payload.sendEmail({
|
const info = await transporter.sendMail({
|
||||||
from: config.mail.from,
|
from: config.mail.from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `Ihre Kontaktanfrage bei ${clientName}`,
|
subject: `Ihre Kontaktanfrage bei ${clientName}`,
|
||||||
html: confirmationHtml,
|
html: confirmationHtml,
|
||||||
});
|
});
|
||||||
|
logger.info("Confirmation email sent successfully", { messageId: info.messageId });
|
||||||
} catch (confirmError) {
|
} catch (confirmError) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to send confirmation email, but notification was sent",
|
"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"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Public Router – paths that bypass Gatekeeper auth
|
# 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.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
|
||||||
export default createMiddleware({
|
const intlMiddleware = createMiddleware({
|
||||||
// A list of all locales that are supported
|
// A list of all locales that are supported
|
||||||
locales: ["de"],
|
locales: ["de"],
|
||||||
|
|
||||||
@@ -11,6 +13,17 @@ export default createMiddleware({
|
|||||||
localePrefix: "as-needed",
|
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 = {
|
export const config = {
|
||||||
// Matcher for all pages and internationalized pathnames
|
// Matcher for all pages and internationalized pathnames
|
||||||
// excluding api, _next, static files, etc.
|
// excluding api, _next, static files, etc.
|
||||||
|
|||||||
@@ -50,24 +50,29 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
prodMigrations: migrations,
|
prodMigrations: migrations,
|
||||||
}),
|
}),
|
||||||
...(process.env.SMTP_HOST
|
email: nodemailerAdapter({
|
||||||
? {
|
defaultFromAddress:
|
||||||
email: nodemailerAdapter({
|
process.env.MAIL_FROM ||
|
||||||
defaultFromAddress:
|
process.env.SMTP_FROM ||
|
||||||
process.env.SMTP_FROM || "info@mb-grid-solutions.com",
|
"info@mb-grid-solutions.com",
|
||||||
defaultFromName: "MB Grid Solutions CMS",
|
defaultFromName: "MB Grid Solutions CMS",
|
||||||
transportOptions: {
|
transportOptions: {
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.MAIL_HOST || process.env.SMTP_HOST || "localhost",
|
||||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
port: parseInt(
|
||||||
auth: {
|
process.env.MAIL_PORT || process.env.SMTP_PORT || "587",
|
||||||
user: process.env.SMTP_USER,
|
),
|
||||||
pass: process.env.SMTP_PASS,
|
auth: {
|
||||||
},
|
user:
|
||||||
secure: process.env.SMTP_SECURE === "true",
|
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,
|
sharp,
|
||||||
plugins: [
|
plugins: [
|
||||||
...(process.env.S3_ENDPOINT
|
...(process.env.S3_ENDPOINT
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ const { mockCreate, mockSendEmail } = vi.hoisted(() => ({
|
|||||||
mockSendEmail: vi.fn(),
|
mockSendEmail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock Nodemailer
|
||||||
|
const { mockSendMail } = vi.hoisted(() => ({
|
||||||
|
mockSendMail: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
default: {
|
||||||
|
createTransport: vi.fn().mockReturnValue({
|
||||||
|
sendMail: mockSendMail,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("payload", () => ({
|
vi.mock("payload", () => ({
|
||||||
getPayload: vi.fn().mockResolvedValue({
|
getPayload: vi.fn().mockResolvedValue({
|
||||||
create: mockCreate,
|
create: mockCreate,
|
||||||
@@ -101,7 +114,7 @@ describe("Contact API Integration", () => {
|
|||||||
|
|
||||||
// But it actually does NOTHING internally
|
// But it actually does NOTHING internally
|
||||||
expect(mockCreate).not.toHaveBeenCalled();
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
expect(mockSendMail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should successfully save to Payload and send emails", async () => {
|
it("should successfully save to Payload and send emails", async () => {
|
||||||
@@ -140,10 +153,10 @@ describe("Contact API Integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Verify Email Sending
|
// 2. Verify Email Sending
|
||||||
// Note: sendEmail is called twice (Notification + User Confirmation)
|
// Note: sendMail is called twice (Notification + User Confirmation)
|
||||||
expect(mockSendEmail).toHaveBeenCalledTimes(2);
|
expect(mockSendMail).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
expect(mockSendMail).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "Kontaktanfrage von Jane Doe",
|
subject: "Kontaktanfrage von Jane Doe",
|
||||||
@@ -151,7 +164,7 @@ describe("Contact API Integration", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
expect(mockSendMail).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "jane@example.com",
|
to: "jane@example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user