From 68bfe882125c6af6a75d79dcce29328022a2c2b8 Mon Sep 17 00:00:00 2001 From: KeplerAeroIT Date: Thu, 7 May 2026 13:43:14 +0530 Subject: [PATCH 1/5] Added OIDC support for Authentik --- backend/src/database/init.ts | 1 + backend/src/database/migrations.sql | 3 + backend/src/routes/auth.ts | 125 ++++++++++ backend/src/utils/oidc.ts | 246 +++++++++++++++++++ docker-compose.yml | 2 +- frontend/src/routes/auth/callback/+server.ts | 47 ++++ frontend/src/routes/login/+page.server.ts | 29 ++- frontend/src/routes/login/+page.svelte | 33 +++ 8 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 backend/src/utils/oidc.ts create mode 100644 frontend/src/routes/auth/callback/+server.ts diff --git a/backend/src/database/init.ts b/backend/src/database/init.ts index 0f80437..e14fe15 100644 --- a/backend/src/database/init.ts +++ b/backend/src/database/init.ts @@ -196,6 +196,7 @@ function ensureUserColumns(database: DB): void { "INTEGER NOT NULL DEFAULT 0", ); addColumnIfMissing(database, "users", "two_factor_recovery_codes", "TEXT"); + addColumnIfMissing(database, "users", "oidc_subject", "TEXT"); } function ensureStatusHistoryTable(database: DB): void { diff --git a/backend/src/database/migrations.sql b/backend/src/database/migrations.sql index 2277e4f..81a5ad8 100644 --- a/backend/src/database/migrations.sql +++ b/backend/src/database/migrations.sql @@ -221,6 +221,9 @@ CREATE TABLE IF NOT EXISTS user_permissions ( UNIQUE(user_id, resource, action) ); +ALTER TABLE users ADD COLUMN oidc_subject TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_subject ON users(oidc_subject) WHERE oidc_subject IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index acb6875..0fc31b1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -22,6 +22,13 @@ import { } from "../middleware/rateLimiter.ts"; import { generateUUID } from "../utils/uuid.ts"; import { isDemoMode } from "../utils/env.ts"; +import { + buildAuthorizationUrl, + exchangeAndVerify, + getOidcConfig, + type OidcClaims, +} from "../utils/oidc.ts"; +import { getDatabase } from "../database/init.ts"; function getSessionTtlSeconds(): number { const parsed = parseInt(Deno.env.get("SESSION_TTL_SECONDS") || "3600", 10); @@ -307,4 +314,122 @@ authRoutes.post("/auth/recover-2fa", async (c) => { return c.json(session); }); +authRoutes.get("/auth/oidc/authorize", async (c) => { + const oidc = getOidcConfig(); + if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404); + try { + const url = await buildAuthorizationUrl(); + return c.json({ url }); + } catch (err) { + console.error("OIDC authorize error:", err); + return c.json({ error: "Failed to build authorization URL" }, 500); + } +}); + +authRoutes.post("/auth/oidc/callback", async (c) => { + const oidc = getOidcConfig(); + if (!oidc.enabled) return c.json({ error: "OIDC not enabled" }, 404); + + let code: string | undefined; + let state: string | undefined; + try { + const body = await c.req.json(); + code = typeof body.code === "string" ? body.code : undefined; + state = typeof body.state === "string" ? body.state : undefined; + } catch { + return c.json({ error: "Invalid request body" }, 400); + } + + if (!code || !state) return c.json({ error: "Missing code or state" }, 400); + + let claims: OidcClaims; + try { + claims = await exchangeAndVerify(code, state); + } catch (err) { + console.error("OIDC callback error:", err); + return c.json({ error: "OIDC verification failed" }, 401); + } + + const db = getDatabase(); + + // 1. Look up by oidc_subject + let rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE oidc_subject = ?", + [claims.sub], + ) as unknown[][]; + + // 2. Look up by email and bind oidc_subject + if (rows.length === 0 && claims.email) { + rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE email = ?", + [claims.email], + ) as unknown[][]; + if (rows.length > 0) { + db.query( + "UPDATE users SET oidc_subject = ?, updated_at = ? WHERE id = ?", + [claims.sub, new Date().toISOString(), String((rows[0] as unknown[])[0])], + ); + } + } + + // 3. Auto-provision a new user + if (rows.length === 0) { + if (!oidc.autoProvision) { + return c.json( + { error: "No matching Invio account. Contact an administrator." }, + 403, + ); + } + + const baseUsername = ( + claims.preferred_username || claims.name || claims.sub + ) + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 50); + + let username = baseUsername; + let attempt = 0; + while ( + (db.query("SELECT id FROM users WHERE username = ?", [username]) as unknown[][]).length > 0 + ) { + attempt++; + username = `${baseUsername}_${attempt}`; + } + + const id = generateUUID(); + const now = new Date().toISOString(); + db.query( + `INSERT INTO users (id, username, email, display_name, password_hash, is_admin, is_active, oidc_subject, created_at, updated_at) + VALUES (?, ?, ?, ?, 'oidc:no-password', 0, 1, ?, ?, ?)`, + [ + id, + username, + claims.email || null, + claims.name || null, + claims.sub, + now, + now, + ], + ); + + rows = db.query( + "SELECT id, username, is_admin, is_active FROM users WHERE id = ?", + [id], + ) as unknown[][]; + } + + const row = rows[0] as unknown[]; + const userId = String(row[0]); + const username = String(row[1]); + const isAdmin = Boolean(row[2]); + const isActive = Boolean(row[3]); + + if (!isActive) { + return c.json({ error: "Account is disabled" }, 403); + } + + const session = await issueSessionToken({ id: userId, username, isAdmin }); + return c.json(session); +}); + export { authRoutes }; diff --git a/backend/src/utils/oidc.ts b/backend/src/utils/oidc.ts new file mode 100644 index 0000000..169cc1b --- /dev/null +++ b/backend/src/utils/oidc.ts @@ -0,0 +1,246 @@ +import { getEnv } from "./env.ts"; + +export interface OidcConfig { + enabled: boolean; + issuerUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + autoProvision: boolean; +} + +export function getOidcConfig(): OidcConfig { + return { + enabled: (getEnv("OIDC_ENABLED", "false") || "false").toLowerCase() === "true", + issuerUrl: (getEnv("OIDC_ISSUER_URL", "") || "").replace(/\/$/, ""), + clientId: getEnv("OIDC_CLIENT_ID", "") || "", + clientSecret: getEnv("OIDC_CLIENT_SECRET", "") || "", + redirectUri: getEnv("OIDC_REDIRECT_URI", "") || "", + autoProvision: (getEnv("OIDC_AUTO_PROVISION", "false") || "false").toLowerCase() === "true", + }; +} + +export interface OidcClaims { + sub: string; + email?: string; + preferred_username?: string; + name?: string; +} + +// ── Discovery document ───────────────────────────────────────────────────── + +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; +} + +let discoveryCache: DiscoveryDocument | null = null; +let discoveryCacheTime = 0; +const DISCOVERY_TTL_MS = 60 * 60 * 1000; + +async function getDiscovery(): Promise { + const now = Date.now(); + if (discoveryCache && now - discoveryCacheTime < DISCOVERY_TTL_MS) { + return discoveryCache; + } + const { issuerUrl } = getOidcConfig(); + if (!issuerUrl) throw new Error("OIDC_ISSUER_URL is not configured"); + const resp = await fetch(`${issuerUrl}/.well-known/openid-configuration`); + if (!resp.ok) throw new Error(`OIDC discovery failed: ${resp.status}`); + discoveryCache = (await resp.json()) as DiscoveryDocument; + discoveryCacheTime = now; + return discoveryCache; +} + +// ── JWKS cache ───────────────────────────────────────────────────────────── + +let jwksCache: JsonWebKey[] | null = null; +let jwksCacheTime = 0; +const JWKS_TTL_MS = 60 * 60 * 1000; + +async function getJwks(jwksUri: string): Promise { + const now = Date.now(); + if (jwksCache && now - jwksCacheTime < JWKS_TTL_MS) return jwksCache; + const resp = await fetch(jwksUri); + if (!resp.ok) throw new Error(`JWKS fetch failed: ${resp.status}`); + const body = (await resp.json()) as { keys: JsonWebKey[] }; + jwksCache = body.keys; + jwksCacheTime = now; + return jwksCache; +} + +// ── State / nonce store ──────────────────────────────────────────────────── + +interface OidcState { + nonce: string; + createdAt: number; +} + +const stateStore = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; + +setInterval(() => { + const cutoff = Date.now() - STATE_TTL_MS; + for (const [key, val] of stateStore.entries()) { + if (val.createdAt < cutoff) stateStore.delete(key); + } +}, 5 * 60 * 1000); + +function randomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +// ── Public: build authorization URL ─────────────────────────────────────── + +export async function buildAuthorizationUrl(): Promise { + const config = getOidcConfig(); + if (!config.clientId) throw new Error("OIDC_CLIENT_ID is not configured"); + if (!config.redirectUri) throw new Error("OIDC_REDIRECT_URI is not configured"); + + const discovery = await getDiscovery(); + const state = randomHex(16); + const nonce = randomHex(16); + stateStore.set(state, { nonce, createdAt: Date.now() }); + + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + redirect_uri: config.redirectUri, + scope: "openid email profile", + state, + nonce, + }); + + return `${discovery.authorization_endpoint}?${params}`; +} + +// ── Public: exchange code + verify ID token ──────────────────────────────── + +export async function exchangeAndVerify( + code: string, + state: string, +): Promise { + const stateEntry = stateStore.get(state); + if (!stateEntry || Date.now() - stateEntry.createdAt > STATE_TTL_MS) { + stateStore.delete(state); + throw new Error("Invalid or expired OIDC state"); + } + const { nonce } = stateEntry; + stateStore.delete(state); + + const config = getOidcConfig(); + const discovery = await getDiscovery(); + + const tokenResp = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!tokenResp.ok) { + const body = await tokenResp.text(); + throw new Error(`Token exchange failed: ${tokenResp.status} ${body}`); + } + + const tokens = (await tokenResp.json()) as { id_token?: string }; + if (!tokens.id_token) throw new Error("No id_token in token response"); + + return verifyIdToken(tokens.id_token, discovery.jwks_uri, config.clientId, discovery.issuer, nonce); +} + +// ── ID token verification (RS256) ───────────────────────────────────────── + +function b64urlToBytes(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +function b64urlDecode(b64url: string): string { + return new TextDecoder().decode(b64urlToBytes(b64url)); +} + +async function verifyIdToken( + idToken: string, + jwksUri: string, + clientId: string, + expectedIssuer: string, + expectedNonce: string, +): Promise { + const parts = idToken.split("."); + if (parts.length !== 3) throw new Error("Malformed ID token"); + const [headerB64, payloadB64, sigB64] = parts; + + const header = JSON.parse(b64urlDecode(headerB64)) as { + alg?: string; + kid?: string; + }; + if (header.alg !== "RS256") { + throw new Error(`Unsupported ID token algorithm: ${header.alg}`); + } + + const jwks = await getJwks(jwksUri); + const jwk = jwks.find( + (k) => + k.kid === header.kid && + (k as Record).use !== "enc" && + ((k as Record).alg === "RS256" || + (k as Record).kty === "RSA"), + ); + if (!jwk) throw new Error(`No matching JWK for kid=${header.kid}`); + + const cryptoKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + + const signedData = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const signature = b64urlToBytes(sigB64); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + cryptoKey, + signature, + signedData, + ); + if (!valid) throw new Error("ID token signature verification failed"); + + const payload = JSON.parse(b64urlDecode(payloadB64)) as Record; + + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp === "number" && payload.exp < now) { + throw new Error("ID token expired"); + } + if (payload.iss !== expectedIssuer) { + throw new Error(`Issuer mismatch: got ${payload.iss}`); + } + const aud = payload.aud; + const audOk = + aud === clientId || (Array.isArray(aud) && aud.includes(clientId)); + if (!audOk) throw new Error("ID token audience mismatch"); + if (payload.nonce !== expectedNonce) { + throw new Error("ID token nonce mismatch"); + } + + return { + sub: String(payload.sub), + email: payload.email ? String(payload.email) : undefined, + preferred_username: payload.preferred_username + ? String(payload.preferred_username) + : undefined, + name: payload.name ? String(payload.name) : undefined, + }; +} diff --git a/docker-compose.yml b/docker-compose.yml index de087a9..bb8abea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: invio services: invio: - image: ghcr.io/kittendevv/invio:latest + build: . env_file: - .env volumes: diff --git a/frontend/src/routes/auth/callback/+server.ts b/frontend/src/routes/auth/callback/+server.ts new file mode 100644 index 0000000..b872776 --- /dev/null +++ b/frontend/src/routes/auth/callback/+server.ts @@ -0,0 +1,47 @@ +import type { RequestHandler } from "./$types"; +import { redirect } from "@sveltejs/kit"; +import { BACKEND_URL, SESSION_COOKIE, DEFAULT_SESSION_MAX_AGE } from "$lib/backend"; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const error = url.searchParams.get("error"); + if (error) { + throw redirect(303, `/login?error=oidc_${encodeURIComponent(error)}`); + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + if (!code || !state) { + throw redirect(303, "/login?error=oidc_missing_params"); + } + + let resp: Response; + try { + resp = await fetch(`${BACKEND_URL}/api/v1/auth/oidc/callback`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code, state }), + }); + } catch { + throw redirect(303, "/login?error=oidc_server_error"); + } + + if (!resp.ok) { + throw redirect(303, "/login?error=oidc_failed"); + } + + const data = await resp.json(); + if (!data?.token) { + throw redirect(303, "/login?error=oidc_failed"); + } + + cookies.set(SESSION_COOKIE, data.token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: false, + maxAge: data.expiresIn ?? DEFAULT_SESSION_MAX_AGE, + }); + + throw redirect(303, "/dashboard"); +}; diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 9331740..44a6b67 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -6,11 +6,21 @@ import { DEFAULT_SESSION_MAX_AGE, } from "$lib/backend"; import { getDemoMode } from "$lib/demo"; +import { env } from "$env/dynamic/private"; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, url }) => { if (locals.user) { throw redirect(303, "/dashboard"); } + const oidcEnabled = + (env.OIDC_ENABLED || "false").toLowerCase() === "true"; + const urlError = url.searchParams.get("error"); + return { + oidcEnabled, + oidcError: urlError?.startsWith("oidc_") + ? "SSO login failed. Please try again or use username and password." + : null, + }; }; export const actions: Actions = { login: async ({ request, cookies }) => { @@ -157,4 +167,21 @@ export const actions: Actions = { throw redirect(303, "/dashboard"); }, + + oidcLogin: async () => { + let resp: Response; + try { + resp = await fetch(`${BACKEND_URL}/api/v1/auth/oidc/authorize`); + } catch { + return fail(500, { error: "Unable to reach authentication server" }); + } + if (!resp.ok) { + return fail(503, { error: "SSO login is not available" }); + } + const data = await resp.json(); + if (!data?.url) { + return fail(500, { error: "Invalid SSO response" }); + } + throw redirect(303, data.url); + }, }; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 8f5a1cf..429a84b 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -8,6 +8,7 @@ let t = getContext("i18n") as (key: string, params?: Record) => string; let isLoading = $state(false); + let oidcLoading = $state(false);
@@ -29,6 +30,38 @@
{/if} + {#if page.data.oidcError} +
+ {page.data.oidcError} +
+ {/if} + + {#if page.data.oidcEnabled && !form?.twoFactorRequired} +
{ + oidcLoading = true; + return async ({ update }) => { + await update(); + oidcLoading = false; + }; + }} + > + +
+
{t("or")}
+ {/if} +
Date: Thu, 7 May 2026 14:40:26 +0530 Subject: [PATCH 2/5] Added SMTP support for email notifications and updated invoice details page to show email status. --- backend/src/routes/admin.ts | 178 ++++++++++++++++++ backend/src/utils/email.ts | 75 ++++++++ .../src/routes/invoices/[id]/+page.server.ts | 30 +++ .../src/routes/invoices/[id]/+page.svelte | 136 ++++++++++++- 4 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 backend/src/utils/email.ts diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9dda7e6..ee8185f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -66,6 +66,7 @@ import { updateUnit, } from "../controllers/productOptions.ts"; import { buildInvoiceHTML, generatePDF } from "../utils/pdf.ts"; +import { isEmailConfigured, sendEmail } from "../utils/email.ts"; import { generateUBLInvoiceXML } from "../utils/ubl.ts"; // legacy direct import import { generateInvoiceXML, listXMLProfiles } from "../utils/xmlProfiles.ts"; import { availableInvoiceLocales } from "../i18n/translations.ts"; @@ -1627,6 +1628,183 @@ adminRoutes.get( }, ); +// Send invoice via email (SMTP2GO) +adminRoutes.post( + "/invoices/:id/send-email", + requirePermission("invoices", "export"), + async (c) => { + if (!isEmailConfigured()) { + return c.json( + { error: "Email is not configured. Set SMTP2GO_API_KEY and EMAIL_FROM_ADDRESS." }, + 503, + ); + } + + const id = c.req.param("id"); + const invoice = getInvoiceById(id); + if (!invoice) return c.json({ error: "Invoice not found" }, 404); + + let to: string[] = []; + let subject = ""; + let message = ""; + try { + const body = await c.req.json(); + to = Array.isArray(body.to) ? body.to.filter((e: unknown) => typeof e === "string" && e.includes("@")) : []; + subject = typeof body.subject === "string" ? body.subject.trim() : ""; + message = typeof body.message === "string" ? body.message.trim() : ""; + } catch { + return c.json({ error: "Invalid request body" }, 400); + } + + if (to.length === 0) { + return c.json({ error: "At least one valid recipient email is required" }, 400); + } + if (!subject) { + return c.json({ error: "Subject is required" }, 400); + } + + // Build settings map (same as /pdf route) + const settings = await getSettings(); + const settingsMap = settings.reduce( + (acc: Record, s) => { acc[s.key] = s.value as string; return acc; }, + {} as Record, + ); + if (!settingsMap.postalCityFormat && settingsMap.postal_city_format) { + settingsMap.postalCityFormat = settingsMap.postal_city_format; + } + if (!settingsMap.logo && settingsMap.logoUrl) { + settingsMap.logo = settingsMap.logoUrl; + } + + const businessSettings = { + companyName: settingsMap.companyName || "Your Company", + companyAddress: settingsMap.companyAddress || "", + companyCity: settingsMap.companyCity || "", + companyPostalCode: settingsMap.companyPostalCode || "", + companyCountryCode: settingsMap.companyCountryCode || "", + postalCityFormat: settingsMap.postalCityFormat || "auto", + companyEmail: settingsMap.companyEmail || "", + companyPhone: settingsMap.companyPhone || "", + companyTaxId: settingsMap.companyTaxId || "", + currency: settingsMap.currency || "USD", + taxLabel: settingsMap.taxLabel || undefined, + logo: settingsMap.logo, + paymentMethods: settingsMap.paymentMethods || "Bank Transfer", + bankAccount: settingsMap.bankAccount || "", + paymentTerms: settingsMap.paymentTerms || "Due in 30 days", + defaultNotes: settingsMap.defaultNotes || "", + locale: settingsMap.locale || undefined, + }; + + const highlight = settingsMap.highlight ?? undefined; + let selectedTemplateId: string | undefined = settingsMap.templateId?.toLowerCase(); + if (selectedTemplateId === "professional" || selectedTemplateId === "professional-modern") { + selectedTemplateId = "professional-modern"; + } else if (selectedTemplateId === "minimalist" || selectedTemplateId === "minimalist-clean") { + selectedTemplateId = "minimalist-clean"; + } + + // Generate PDF attachment + let pdfBuffer: Uint8Array; + try { + const customer = getCustomerById(invoice.customerId); + const renderLocale = resolveInvoiceRenderLocale( + invoice.locale, + customer?.countryCode, + settingsMap.locale, + ); + pdfBuffer = await generatePDF( + invoice, + businessSettings, + selectedTemplateId, + highlight, + { + embedXml: false, + dateFormat: settingsMap.dateFormat, + numberFormat: settingsMap.numberFormat, + locale: renderLocale, + }, + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Email: PDF generation failed:", msg); + return c.json({ error: "Failed to generate PDF attachment", details: msg }, 500); + } + + // Build email body + const companyName = businessSettings.companyName; + const invoiceNumber = invoice.invoiceNumber || invoice.id; + const total = `${Number(invoice.total || 0).toFixed(2)} ${invoice.currency || ""}`.trim(); + const issueDate = invoice.issueDate + ? new Date(invoice.issueDate).toISOString().slice(0, 10) + : ""; + const dueDate = invoice.dueDate + ? new Date(invoice.dueDate).toISOString().slice(0, 10) + : null; + const origin = c.req.header("origin") || + c.req.header("referer")?.replace(/\/$/, "") || ""; + const shareLink = invoice.shareToken && origin + ? `${origin}/public/invoices/${invoice.shareToken}` + : null; + + const messageHtml = message + ? `

${message.replace(/&/g, "&").replace(//g, ">")}

` + : ""; + const shareLinkHtml = shareLink + ? `

View invoice online

` + : ""; + const dueDateHtml = dueDate ? `Due date${dueDate}` : ""; + + const htmlBody = ` + + + +

${companyName}

+ ${messageHtml} + + + + ${dueDateHtml} + +
Invoice#${invoiceNumber}
Issue date${issueDate}
Total${total}
+ ${shareLinkHtml} +

The invoice PDF is attached to this email.

+ +`; + + const textBody = [ + companyName, + "", + message || "", + `Invoice: #${invoiceNumber}`, + `Issue date: ${issueDate}`, + dueDate ? `Due date: ${dueDate}` : "", + `Total: ${total}`, + shareLink ? `\nView online: ${shareLink}` : "", + ].filter((l) => l !== undefined).join("\n").trim(); + + try { + await sendEmail({ + to, + subject, + htmlBody, + textBody, + attachment: { + filename: `invoice-${invoiceNumber}.pdf`, + content: pdfBuffer, + mimeType: "application/pdf", + }, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Email send failed:", msg); + return c.json({ error: "Failed to send email", details: msg }, 502); + } + + return c.json({ sent: true, recipients: to.length }); + }, +); + // UBL (PEPPOL BIS Billing 3.0) XML for an invoice by ID adminRoutes.get( "/invoices/:id/ubl.xml", diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts new file mode 100644 index 0000000..cafd7b0 --- /dev/null +++ b/backend/src/utils/email.ts @@ -0,0 +1,75 @@ +import { getEnv } from "./env.ts"; +import nodemailer from "npm:nodemailer"; +import { Buffer } from "node:buffer"; + +export interface EmailAttachment { + filename: string; + content: Uint8Array; + mimeType: string; +} + +export interface SendEmailOptions { + to: string[]; + subject: string; + htmlBody: string; + textBody: string; + attachment?: EmailAttachment; +} + +export function isEmailConfigured(): boolean { + return Boolean( + getEnv("SMTP_HOST", "") && + getEnv("EMAIL_FROM_ADDRESS", ""), + ); +} + +function getSmtpConfig() { + const host = getEnv("SMTP_HOST"); + const port = parseInt(getEnv("SMTP_PORT", "587") || "587", 10); + const secure = (getEnv("SMTP_SECURE", "false") || "false").toLowerCase() === "true"; + const user = getEnv("SMTP_USER", ""); + const pass = getEnv("SMTP_PASS", ""); + const fromAddress = getEnv("EMAIL_FROM_ADDRESS"); + const fromName = getEnv("EMAIL_FROM_NAME", "") || ""; + + if (!host) throw new Error("SMTP_HOST is not configured."); + if (!fromAddress) throw new Error("EMAIL_FROM_ADDRESS is not configured."); + + return { host, port, secure, user, pass, fromAddress, fromName }; +} + +export async function sendEmail(opts: SendEmailOptions): Promise { + const cfg = getSmtpConfig(); + + const transporter = nodemailer.createTransport({ + host: cfg.host, + port: cfg.port, + secure: cfg.secure, + ...(cfg.user && cfg.pass + ? { auth: { user: cfg.user, pass: cfg.pass } } + : {}), + }); + + const from = cfg.fromName + ? `"${cfg.fromName}" <${cfg.fromAddress}>` + : cfg.fromAddress; + + const attachments = opts.attachment + ? [ + { + filename: opts.attachment.filename, + content: Buffer.from(opts.attachment.content), + contentType: opts.attachment.mimeType, + }, + ] + : []; + + await transporter.sendMail({ + from, + to: opts.to.join(", "), + subject: opts.subject, + html: opts.htmlBody, + text: opts.textBody, + attachments, + }); +} diff --git a/frontend/src/routes/invoices/[id]/+page.server.ts b/frontend/src/routes/invoices/[id]/+page.server.ts index f5df8dd..d35521e 100644 --- a/frontend/src/routes/invoices/[id]/+page.server.ts +++ b/frontend/src/routes/invoices/[id]/+page.server.ts @@ -6,6 +6,7 @@ import { } from "$lib/backend"; import { error, redirect, fail } from "@sveltejs/kit"; import type { PageServerLoad, Actions } from "./$types"; +import { env } from "$env/dynamic/private"; export const load: PageServerLoad = async ({ params, locals, url }) => { if (!locals.authHeader) { @@ -32,6 +33,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { invoice: invoiceRes.value, showPublishedBanner, allowProtectedInvoiceChanges, + emailEnabled: Boolean(env.SMTP_HOST && env.EMAIL_FROM_ADDRESS), }; } catch (err: any) { throw error(404, "Invoice not found"); @@ -102,6 +104,34 @@ export const actions: Actions = { await backendPost(`/api/v1/invoices/${id}/void`, locals.authHeader, {}); throw redirect(303, `/invoices/${id}`); } + if (intent === "send-email") { + const toRaw = String(data.get("emailTo") ?? "").trim(); + const subject = String(data.get("emailSubject") ?? "").trim(); + const message = String(data.get("emailMessage") ?? "").trim(); + + const to = toRaw + .split(",") + .map((e) => e.trim()) + .filter((e) => e.includes("@")); + + if (to.length === 0) { + return fail(400, { emailError: "Enter at least one valid recipient email address." }); + } + if (!subject) { + return fail(400, { emailError: "Subject is required." }); + } + + try { + await backendPost(`/api/v1/invoices/${id}/send-email`, locals.authHeader, { + to, + subject, + message, + }); + return { emailSent: true, emailRecipients: to }; + } catch (e) { + return fail(502, { emailError: `Failed to send: ${String(e)}` }); + } + } } catch (e) { if (e && typeof e === "object" && "status" in e && "location" in e) { // it's a redirect, rethrow it diff --git a/frontend/src/routes/invoices/[id]/+page.svelte b/frontend/src/routes/invoices/[id]/+page.svelte index e92448a..28d8f47 100644 --- a/frontend/src/routes/invoices/[id]/+page.svelte +++ b/frontend/src/routes/invoices/[id]/+page.svelte @@ -1,6 +1,6 @@ + + + + + +
{#if form?.error}
@@ -92,6 +209,16 @@
{/if} + {#if (form as any)?.emailSent} +
+ +
+
{t("Invoice sent successfully")}
+
{t("Sent to")} {(form as any).emailRecipients?.join(", ")}
+
+
+ {/if} + {#if showPublishedBanner && invoice?.shareToken}
@@ -249,6 +376,13 @@
{/if} + {#if emailEnabled && canExport && invoice.status !== "voided"} + + {/if} +