Adjusted SMTP

This commit is contained in:
2026-04-11 12:21:34 +02:00
parent 9a96d74f5c
commit 97b483c121
9 changed files with 339 additions and 24 deletions

55
deploy/setup-smtp.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Session 6.4 — SMTP secret setup for PieCed Portal
#
# 1. Store SMTP credentials in OpenBao
# 2. Apply the ExternalSecret
# 3. Patch the portal deployment to mount the secret
#
# Prerequisites: bao CLI authenticated, kubectl context set
set -e
# ─── Step 1: Store SMTP creds in OpenBao ───────────────────────────────────────
echo "==> Storing SMTP credentials in OpenBao..."
bao kv put pieced/portal/smtp \
host="smtp.gmail.com" \
port="587" \
user="noreply@pieced.ch" \
password="REPLACE_WITH_APP_PASSWORD" \
from="PieCed <noreply@pieced.ch>" \
admin_email="admin@pieced.ch"
echo "==> Verifying..."
bao kv get pieced/portal/smtp
# ─── Step 2: Apply ExternalSecret ──────────────────────────────────────────────
echo "==> Applying ExternalSecret..."
kubectl apply -f deploy/portal-smtp-externalsecret.yaml
echo "==> Waiting for ExternalSecret to sync..."
kubectl wait --for=condition=Ready externalsecret/portal-smtp -n pieced-system --timeout=60s
echo "==> Verifying K8s secret created..."
kubectl get secret portal-smtp -n pieced-system
# ─── Step 3: Patch portal deployment to mount SMTP secret ──────────────────────
echo "==> Patching portal deployment..."
# Add envFrom entry for portal-smtp secret
# If your deployment already uses a patch file, add this to the containers[0].envFrom array instead.
kubectl patch deployment pieced-portal -n pieced-system --type=json -p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/envFrom/-",
"value": {
"secretRef": {
"name": "portal-smtp"
}
}
}
]'
echo "==> Restarting portal..."
kubectl rollout restart deployment pieced-portal -n pieced-system
kubectl rollout status deployment pieced-portal -n pieced-system
echo "==> Done! SMTP credentials are now available to the portal."

20
package-lock.json generated
View File

@@ -9,10 +9,12 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"next": "^15.5.15", "next": "^15.5.15",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-intl": "^4.9.0", "next-intl": "^4.9.0",
"nodemailer": "^7.0.13",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -2015,6 +2017,15 @@
"form-data": "^4.0.4" "form-data": "^4.0.4"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -5824,6 +5835,15 @@
} }
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": { "node_modules/oauth4webapi": {
"version": "3.8.5", "version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",

View File

@@ -11,10 +11,12 @@
}, },
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"next": "^15.5.15", "next": "^15.5.15",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-intl": "^4.9.0", "next-intl": "^4.9.0",
"nodemailer": "^7.0.13",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",

View File

@@ -2,11 +2,12 @@ import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import { createTenant } from "@/lib/k8s"; import { createTenant } from "@/lib/k8s";
import { sendApprovalEmail } from "@/lib/email";
/** /**
* POST /api/admin/requests/[id]/approve * POST /api/admin/requests/[id]/approve
* Approve a tenant request: create the PiecedTenant CR and update status. * Approve a tenant request: create the PiecedTenant CR, update status, notify customer.
* Also supports re-approving a previously rejected request. * Also supports re-approving a previously rejected request (clears admin notes).
*/ */
export async function POST( export async function POST(
request: Request, request: Request,
@@ -37,6 +38,8 @@ export async function POST(
); );
} }
const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens // Derive tenant name from company name: lowercase, alphanumeric + hyphens
const tenantName = tenantRequest.companyName const tenantName = tenantRequest.companyName
.toLowerCase() .toLowerCase()
@@ -61,12 +64,20 @@ export async function POST(
} }
); );
// Update request status // Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", { const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes, adminNotes: isReApproval ? null : adminNotes,
tenantName, tenantName,
clearAdminNotes: isReApproval,
}); });
// Notify customer
await sendApprovalEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName
);
return NextResponse.json({ return NextResponse.json({
message: "Tenant approved and provisioning started.", message: "Tenant approved and provisioning started.",
request: updated, request: updated,

View File

@@ -1,10 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import { sendRejectionEmail } from "@/lib/email";
/** /**
* POST /api/admin/requests/[id]/reject * POST /api/admin/requests/[id]/reject
* Reject a tenant request. * Reject a tenant request and notify the customer.
*/ */
export async function POST( export async function POST(
request: Request, request: Request,
@@ -36,6 +37,14 @@ export async function POST(
adminNotes, adminNotes,
}); });
// Notify customer
await sendRejectionEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.companyName,
adminNotes
);
return NextResponse.json({ return NextResponse.json({
message: "Request rejected.", message: "Request rejected.",
request: updated, request: updated,

View File

@@ -1,10 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { listTenantRequests } from "@/lib/db"; import { listTenantRequests, syncProvisioningStatuses } from "@/lib/db";
import { getTenant } from "@/lib/k8s";
/** /**
* GET /api/admin/requests * GET /api/admin/requests
* List all tenant requests. Optionally filter by ?status=pending * List all tenant requests. Optionally filter by ?status=pending
* Auto-syncs "provisioning" → "active" when the PiecedTenant CR is Ready.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
@@ -13,6 +15,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Sync provisioning statuses before listing
await syncProvisioningStatuses(async (tenantName: string) => {
const tenant = await getTenant(tenantName);
return tenant?.status?.phase ?? null;
});
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as any; const status = searchParams.get("status") as any;

View File

@@ -5,6 +5,7 @@ import {
getTenantRequestByOrgId, getTenantRequestByOrgId,
} from "@/lib/db"; } from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s"; import { getTenant, listTenants } from "@/lib/k8s";
import { sendAdminNotificationEmail } from "@/lib/email";
import type { OnboardingInput } from "@/types"; import type { OnboardingInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
@@ -87,6 +88,7 @@ export async function GET() {
* POST /api/onboarding * POST /api/onboarding
* Submit the onboarding wizard. Creates a tenant_request with status "pending". * Submit the onboarding wizard. Creates a tenant_request with status "pending".
* The actual PiecedTenant CR is NOT created yet — admin approval required. * The actual PiecedTenant CR is NOT created yet — admin approval required.
* Sends a notification email to the admin.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -138,6 +140,13 @@ export async function POST(request: Request) {
billingNotes: input.billingNotes, billingNotes: input.billingNotes,
}); });
// Notify admin about the new request
await sendAdminNotificationEmail(
user.orgName,
user.name || user.email,
user.email
);
return NextResponse.json( return NextResponse.json(
{ message: "Onboarding request submitted.", request: tenantRequest }, { message: "Onboarding request submitted.", request: tenantRequest },
{ status: 201 } { status: 201 }

View File

@@ -132,12 +132,19 @@ export async function listTenantRequests(
export async function updateTenantRequestStatus( export async function updateTenantRequestStatus(
id: string, id: string,
status: TenantRequestStatus, status: TenantRequestStatus,
extra?: { adminNotes?: string; tenantName?: string } extra?: { adminNotes?: string | null; tenantName?: string; clearAdminNotes?: boolean }
): Promise<TenantRequest> { ): Promise<TenantRequest> {
await ensureSchema(); await ensureSchema();
// If clearAdminNotes is true, explicitly set admin_notes to NULL
// Otherwise use COALESCE to preserve existing value when not provided
const adminNotesExpr = extra?.clearAdminNotes
? "$2"
: "COALESCE($2, admin_notes)";
const result = await getPool().query( const result = await getPool().query(
`UPDATE tenant_requests `UPDATE tenant_requests
SET status = $1, admin_notes = COALESCE($2, admin_notes), SET status = $1, admin_notes = ${adminNotesExpr},
tenant_name = COALESCE($3, tenant_name), updated_at = now() tenant_name = COALESCE($3, tenant_name), updated_at = now()
WHERE id = $4 WHERE id = $4
RETURNING *`, RETURNING *`,
@@ -147,6 +154,35 @@ export async function updateTenantRequestStatus(
return mapRow(result.rows[0]); return mapRow(result.rows[0]);
} }
/**
* Sync provisioning statuses: for all requests with status "provisioning",
* check if the PiecedTenant CR has reached "Ready" and update to "active".
* Called from the admin requests list endpoint.
*/
export async function syncProvisioningStatuses(
checkTenantPhase: (tenantName: string) => Promise<string | null>
): Promise<void> {
await ensureSchema();
const pool = getPool();
const result = await pool.query(
"SELECT id, tenant_name FROM tenant_requests WHERE status = 'provisioning' AND tenant_name IS NOT NULL"
);
for (const row of result.rows) {
try {
const phase = await checkTenantPhase(row.tenant_name);
if (phase === "Ready" || phase === "Running") {
await pool.query(
"UPDATE tenant_requests SET status = 'active', updated_at = now() WHERE id = $1",
[row.id]
);
}
} catch (e) {
console.error(`Failed to sync status for request ${row.id}:`, e);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Row mapping (snake_case → camelCase) // Row mapping (snake_case → camelCase)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

165
src/lib/email.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Email sending utility for the PieCed portal.
*
* Uses nodemailer with SMTP credentials from environment variables
* (populated via ExternalSecret from OpenBao at pieced/portal/smtp).
*
* Env vars (from portal-smtp K8s secret):
* SMTP_HOST — e.g. smtp.gmail.com
* SMTP_PORT — e.g. 587 (default)
* SMTP_USER — e.g. noreply@pieced.ch
* SMTP_PASS — App Password
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
*/
import nodemailer from "nodemailer";
let _transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!_transporter) {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error("SMTP_HOST, SMTP_USER, and SMTP_PASS must be set");
}
_transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: process.env.SMTP_SECURE === "true",
auth: { user, pass },
});
}
return _transporter;
}
function getFrom(): string {
return (
process.env.SMTP_FROM ||
`PieCed <${process.env.SMTP_USER}>`
);
}
export async function sendApprovalEmail(
to: string,
contactName: string,
companyName: string
): Promise<void> {
try {
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Your PieCed AI assistant is being set up — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Great news! Your onboarding request for ${companyName} has been approved.`,
"",
"Your AI assistant instance is now being provisioned. This usually takes a few minutes.",
"You can check the status in your dashboard at https://app.pieced.ch",
"",
"Once your instance is ready, you'll see it on your dashboard and can start configuring it.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Your AI assistant is being set up</h2>
<p>Hello ${contactName},</p>
<p>Great news! Your onboarding request for <strong>${companyName}</strong> has been approved.</p>
<p>Your AI assistant instance is now being provisioned. This usually takes a few minutes.</p>
<p>
<a href="https://app.pieced.ch" style="display: inline-block; padding: 10px 24px; background: #3b82f6; color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500;">
Go to Dashboard
</a>
</p>
<p style="color: #888; font-size: 13px; margin-top: 24px;">
Once your instance is ready, you'll see it on your dashboard and can start configuring it.
</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send approval email:", err);
}
}
export async function sendRejectionEmail(
to: string,
contactName: string,
companyName: string,
adminNotes?: string
): Promise<void> {
try {
const notesBlock = adminNotes
? `\nNote from our team:\n${adminNotes}\n`
: "";
const notesHtml = adminNotes
? `<div style="background: #2a2a2a; border-left: 3px solid #ef4444; padding: 12px 16px; border-radius: 6px; margin: 16px 0;">
<p style="color: #ccc; font-size: 13px; margin: 0;"><strong>Note from our team:</strong></p>
<p style="color: #aaa; font-size: 13px; margin: 8px 0 0 0;">${adminNotes}</p>
</div>`
: "";
await getTransporter().sendMail({
from: getFrom(),
to,
subject: `Update on your PieCed onboarding request — ${companyName}`,
text: [
`Hello ${contactName},`,
"",
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
notesBlock,
"If you have questions or would like to discuss this further, please reply to this email.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${contactName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${companyName}</strong> at this time.</p>
${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send rejection email:", err);
}
}
export async function sendAdminNotificationEmail(
companyName: string,
contactName: string,
contactEmail: string
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `New onboarding request: ${companyName}`,
text: [
`A new onboarding request has been submitted.`,
"",
`Company: ${companyName}`,
`Contact: ${contactName} (${contactEmail})`,
"",
`Review it at https://app.pieced.ch/admin`,
].join("\n"),
});
} catch (err) {
console.error("Failed to send admin notification email:", err);
}
}