@@ -66,6 +66,7 @@ import {
6666 updateUnit ,
6767} from "../controllers/productOptions.ts" ;
6868import { buildInvoiceHTML , generatePDF } from "../utils/pdf.ts" ;
69+ import { isEmailConfigured , sendEmail } from "../utils/email.ts" ;
6970import { generateUBLInvoiceXML } from "../utils/ubl.ts" ; // legacy direct import
7071import { generateInvoiceXML , listXMLProfiles } from "../utils/xmlProfiles.ts" ;
7172import { availableInvoiceLocales } from "../i18n/translations.ts" ;
@@ -1654,6 +1655,183 @@ adminRoutes.get(
16541655 } ,
16551656) ;
16561657
1658+ // Send invoice via email (SMTP2GO)
1659+ adminRoutes . post (
1660+ "/invoices/:id/send-email" ,
1661+ requirePermission ( "invoices" , "export" ) ,
1662+ async ( c ) => {
1663+ if ( ! isEmailConfigured ( ) ) {
1664+ return c . json (
1665+ { error : "Email is not configured. Set SMTP2GO_API_KEY and EMAIL_FROM_ADDRESS." } ,
1666+ 503 ,
1667+ ) ;
1668+ }
1669+
1670+ const id = c . req . param ( "id" ) ;
1671+ const invoice = getInvoiceById ( id ) ;
1672+ if ( ! invoice ) return c . json ( { error : "Invoice not found" } , 404 ) ;
1673+
1674+ let to : string [ ] = [ ] ;
1675+ let subject = "" ;
1676+ let message = "" ;
1677+ try {
1678+ const body = await c . req . json ( ) ;
1679+ to = Array . isArray ( body . to ) ? body . to . filter ( ( e : unknown ) => typeof e === "string" && e . includes ( "@" ) ) : [ ] ;
1680+ subject = typeof body . subject === "string" ? body . subject . trim ( ) : "" ;
1681+ message = typeof body . message === "string" ? body . message . trim ( ) : "" ;
1682+ } catch {
1683+ return c . json ( { error : "Invalid request body" } , 400 ) ;
1684+ }
1685+
1686+ if ( to . length === 0 ) {
1687+ return c . json ( { error : "At least one valid recipient email is required" } , 400 ) ;
1688+ }
1689+ if ( ! subject ) {
1690+ return c . json ( { error : "Subject is required" } , 400 ) ;
1691+ }
1692+
1693+ // Build settings map (same as /pdf route)
1694+ const settings = await getSettings ( ) ;
1695+ const settingsMap = settings . reduce (
1696+ ( acc : Record < string , string > , s ) => { acc [ s . key ] = s . value as string ; return acc ; } ,
1697+ { } as Record < string , string > ,
1698+ ) ;
1699+ if ( ! settingsMap . postalCityFormat && settingsMap . postal_city_format ) {
1700+ settingsMap . postalCityFormat = settingsMap . postal_city_format ;
1701+ }
1702+ if ( ! settingsMap . logo && settingsMap . logoUrl ) {
1703+ settingsMap . logo = settingsMap . logoUrl ;
1704+ }
1705+
1706+ const businessSettings = {
1707+ companyName : settingsMap . companyName || "Your Company" ,
1708+ companyAddress : settingsMap . companyAddress || "" ,
1709+ companyCity : settingsMap . companyCity || "" ,
1710+ companyPostalCode : settingsMap . companyPostalCode || "" ,
1711+ companyCountryCode : settingsMap . companyCountryCode || "" ,
1712+ postalCityFormat : settingsMap . postalCityFormat || "auto" ,
1713+ companyEmail : settingsMap . companyEmail || "" ,
1714+ companyPhone : settingsMap . companyPhone || "" ,
1715+ companyTaxId : settingsMap . companyTaxId || "" ,
1716+ currency : settingsMap . currency || "USD" ,
1717+ taxLabel : settingsMap . taxLabel || undefined ,
1718+ logo : settingsMap . logo ,
1719+ paymentMethods : settingsMap . paymentMethods || "Bank Transfer" ,
1720+ bankAccount : settingsMap . bankAccount || "" ,
1721+ paymentTerms : settingsMap . paymentTerms || "Due in 30 days" ,
1722+ defaultNotes : settingsMap . defaultNotes || "" ,
1723+ locale : settingsMap . locale || undefined ,
1724+ } ;
1725+
1726+ const highlight = settingsMap . highlight ?? undefined ;
1727+ let selectedTemplateId : string | undefined = settingsMap . templateId ?. toLowerCase ( ) ;
1728+ if ( selectedTemplateId === "professional" || selectedTemplateId === "professional-modern" ) {
1729+ selectedTemplateId = "professional-modern" ;
1730+ } else if ( selectedTemplateId === "minimalist" || selectedTemplateId === "minimalist-clean" ) {
1731+ selectedTemplateId = "minimalist-clean" ;
1732+ }
1733+
1734+ // Generate PDF attachment
1735+ let pdfBuffer : Uint8Array ;
1736+ try {
1737+ const customer = getCustomerById ( invoice . customerId ) ;
1738+ const renderLocale = resolveInvoiceRenderLocale (
1739+ invoice . locale ,
1740+ customer ?. countryCode ,
1741+ settingsMap . locale ,
1742+ ) ;
1743+ pdfBuffer = await generatePDF (
1744+ invoice ,
1745+ businessSettings ,
1746+ selectedTemplateId ,
1747+ highlight ,
1748+ {
1749+ embedXml : false ,
1750+ dateFormat : settingsMap . dateFormat ,
1751+ numberFormat : settingsMap . numberFormat ,
1752+ locale : renderLocale ,
1753+ } ,
1754+ ) ;
1755+ } catch ( e ) {
1756+ const msg = e instanceof Error ? e . message : String ( e ) ;
1757+ console . error ( "Email: PDF generation failed:" , msg ) ;
1758+ return c . json ( { error : "Failed to generate PDF attachment" , details : msg } , 500 ) ;
1759+ }
1760+
1761+ // Build email body
1762+ const companyName = businessSettings . companyName ;
1763+ const invoiceNumber = invoice . invoiceNumber || invoice . id ;
1764+ const total = `${ Number ( invoice . total || 0 ) . toFixed ( 2 ) } ${ invoice . currency || "" } ` . trim ( ) ;
1765+ const issueDate = invoice . issueDate
1766+ ? new Date ( invoice . issueDate ) . toISOString ( ) . slice ( 0 , 10 )
1767+ : "" ;
1768+ const dueDate = invoice . dueDate
1769+ ? new Date ( invoice . dueDate ) . toISOString ( ) . slice ( 0 , 10 )
1770+ : null ;
1771+ const origin = c . req . header ( "origin" ) ||
1772+ c . req . header ( "referer" ) ?. replace ( / \/ $ / , "" ) || "" ;
1773+ const shareLink = invoice . shareToken && origin
1774+ ? `${ origin } /public/invoices/${ invoice . shareToken } `
1775+ : null ;
1776+
1777+ const messageHtml = message
1778+ ? `<p style="white-space:pre-wrap;">${ message . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) } </p>`
1779+ : "" ;
1780+ const shareLinkHtml = shareLink
1781+ ? `<p><a href="${ shareLink } " style="color:#2563eb;">View invoice online</a></p>`
1782+ : "" ;
1783+ const dueDateHtml = dueDate ? `<tr><td style="padding:4px 8px;color:#6b7280;">Due date</td><td style="padding:4px 8px;">${ dueDate } </td></tr>` : "" ;
1784+
1785+ const htmlBody = `<!DOCTYPE html>
1786+ <html>
1787+ <head><meta charset="utf-8"></head>
1788+ <body style="font-family:sans-serif;color:#111;max-width:600px;margin:0 auto;padding:24px;">
1789+ <h2 style="margin-top:0;">${ companyName } </h2>
1790+ ${ messageHtml }
1791+ <table style="border-collapse:collapse;margin:16px 0;width:auto;">
1792+ <tr><td style="padding:4px 8px;color:#6b7280;">Invoice</td><td style="padding:4px 8px;font-weight:600;">#${ invoiceNumber } </td></tr>
1793+ <tr><td style="padding:4px 8px;color:#6b7280;">Issue date</td><td style="padding:4px 8px;">${ issueDate } </td></tr>
1794+ ${ dueDateHtml }
1795+ <tr><td style="padding:4px 8px;color:#6b7280;">Total</td><td style="padding:4px 8px;font-weight:600;">${ total } </td></tr>
1796+ </table>
1797+ ${ shareLinkHtml }
1798+ <p style="color:#6b7280;font-size:13px;">The invoice PDF is attached to this email.</p>
1799+ </body>
1800+ </html>` ;
1801+
1802+ const textBody = [
1803+ companyName ,
1804+ "" ,
1805+ message || "" ,
1806+ `Invoice: #${ invoiceNumber } ` ,
1807+ `Issue date: ${ issueDate } ` ,
1808+ dueDate ? `Due date: ${ dueDate } ` : "" ,
1809+ `Total: ${ total } ` ,
1810+ shareLink ? `\nView online: ${ shareLink } ` : "" ,
1811+ ] . filter ( ( l ) => l !== undefined ) . join ( "\n" ) . trim ( ) ;
1812+
1813+ try {
1814+ await sendEmail ( {
1815+ to,
1816+ subject,
1817+ htmlBody,
1818+ textBody,
1819+ attachment : {
1820+ filename : `invoice-${ invoiceNumber } .pdf` ,
1821+ content : pdfBuffer ,
1822+ mimeType : "application/pdf" ,
1823+ } ,
1824+ } ) ;
1825+ } catch ( e ) {
1826+ const msg = e instanceof Error ? e . message : String ( e ) ;
1827+ console . error ( "Email send failed:" , msg ) ;
1828+ return c . json ( { error : "Failed to send email" , details : msg } , 502 ) ;
1829+ }
1830+
1831+ return c . json ( { sent : true , recipients : to . length } ) ;
1832+ } ,
1833+ ) ;
1834+
16571835// UBL (PEPPOL BIS Billing 3.0) XML for an invoice by ID
16581836adminRoutes . get (
16591837 "/invoices/:id/ubl.xml" ,
0 commit comments