diff --git a/.env.example b/.env.example index 941e0fa..176c4a2 100644 --- a/.env.example +++ b/.env.example @@ -33,4 +33,8 @@ INTEGRATIONS_JWKS='{ BRBX_BILLING_DRY_RUN_COMPANY_ID= BRBX_BILLING_DRY_RUN_JWT= BRBX_BILLING_LIVE_COMPANY_ID= -BRBX_BILLING_LIVE_JWT= \ No newline at end of file +BRBX_BILLING_LIVE_JWT= + +# Postmark inbound +POSTMARK_INBOUND_WEBHOOK_USERNAME= +POSTMARK_INBOUND_WEBHOOK_PASSWORD= \ No newline at end of file diff --git a/api/companies/email/outbound.ts b/api/companies/email/outbound.ts new file mode 100644 index 0000000..93256e4 --- /dev/null +++ b/api/companies/email/outbound.ts @@ -0,0 +1,73 @@ +import { Server } from "@recommand/lib/api"; +import { describeRoute } from "hono-openapi"; +import { zodValidator } from "@recommand/lib/zod-validator"; +import { actionSuccess, actionFailure } from "@recommand/lib/utils"; +import { requireCompanyAccess, type CompanyAccessContext } from "@peppol/utils/auth-middleware"; +import { updateCompany } from "@peppol/data/companies"; +import type { Context } from "@recommand/lib/api"; +import type { AuthenticatedUserContext, AuthenticatedTeamContext } from "@core/lib/auth-middleware"; +import { z } from "zod"; + +const server = new Server(); + +const updateOutboundEmailSchema = z.object({ + slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, "Slug must contain only lowercase letters, numbers, and hyphens"), + enabled: z.boolean(), +}); + +type UpdateOutboundEmailContext = Context< + AuthenticatedUserContext & AuthenticatedTeamContext & CompanyAccessContext, + string, + { + in: { json: z.input }; + out: { json: z.infer }; + } +>; + +const _updateOutboundEmail = server.put( + "/:teamId/companies/:companyId/email/outbound", + requireCompanyAccess(), + describeRoute({ + operationId: "updateCompanyOutboundEmail", + summary: "Update company outbound email settings", + description: "Update the email-to-Peppol sending settings for a company", + tags: ["Companies"], + }), + zodValidator("json", updateOutboundEmailSchema), + async (c: UpdateOutboundEmailContext) => { + try { + const { slug, enabled } = c.req.valid("json"); + const company = c.var.company; + + const updatedCompany = await updateCompany({ + id: company.id, + teamId: company.teamId, + outboundEmailSlug: slug, + outboundEmailEnabled: enabled, + }); + + const outboundEmailAddress = slug + ? `${slug}@out.recommand.eu` + : null; + + return c.json( + actionSuccess({ + company: updatedCompany, + outboundEmailAddress, + }) + ); + } catch (error) { + console.error("Error updating outbound email settings:", error); + return c.json( + actionFailure( + error instanceof Error ? error.message : "Failed to update outbound email settings" + ), + 400 + ); + } + } +); + +export type CompanyEmailOutbound = typeof _updateOutboundEmail; + +export default server; diff --git a/api/companies/index.ts b/api/companies/index.ts index 1e9a128..8615ca1 100644 --- a/api/companies/index.ts +++ b/api/companies/index.ts @@ -8,8 +8,9 @@ import deleteCompanyServer, { type DeleteCompany } from "./delete-company"; import companyIdentifiersServer, { type CompanyIdentifiers } from "./identifiers"; import companyDocumentTypesServer, { type CompanyDocumentTypes } from "./document-types"; import companyNotificationEmailAddressesServer, { type CompanyNotificationEmailAddresses } from "./notification-email-addresses"; +import companyEmailOutboundServer, { type CompanyEmailOutbound } from "./email/outbound"; -export type Companies = GetCompanies | GetCompany | CreateCompany | UpdateCompany | DeleteCompany | CompanyIdentifiers | CompanyDocumentTypes | CompanyNotificationEmailAddresses; +export type Companies = GetCompanies | GetCompany | CreateCompany | UpdateCompany | DeleteCompany | CompanyIdentifiers | CompanyDocumentTypes | CompanyNotificationEmailAddresses | CompanyEmailOutbound; const server = new Server(); server.route("/", getCompaniesServer); @@ -20,4 +21,5 @@ server.route("/", deleteCompanyServer); server.route("/", companyIdentifiersServer); server.route("/", companyDocumentTypesServer); server.route("/", companyNotificationEmailAddressesServer); +server.route("/", companyEmailOutboundServer); export default server; \ No newline at end of file diff --git a/api/companies/shared.ts b/api/companies/shared.ts index 9abd1a3..88af1da 100644 --- a/api/companies/shared.ts +++ b/api/companies/shared.ts @@ -8,9 +8,13 @@ export const companyResponse = z.object({ postalCode: z.string(), city: z.string(), country: z.string(), - enterpriseNumber: z.string(), - vatNumber: z.string(), + enterpriseNumber: z.string().nullable(), + vatNumber: z.string().nullable(), isSmpRecipient: z.boolean(), + outboundEmailSlug: z.string().nullable(), + outboundEmailEnabled: z.boolean(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), -}); \ No newline at end of file +}); + +export type CompanyResponse = z.infer; \ No newline at end of file diff --git a/api/internal/webhooks/email/index.ts b/api/internal/webhooks/email/index.ts new file mode 100644 index 0000000..8fe4437 --- /dev/null +++ b/api/internal/webhooks/email/index.ts @@ -0,0 +1,7 @@ +import { Server } from "@recommand/lib/api"; +import outboundServer from "./outbound"; + +const server = new Server(); +server.route("/", outboundServer); + +export default server; diff --git a/api/internal/webhooks/email/outbound.ts b/api/internal/webhooks/email/outbound.ts new file mode 100644 index 0000000..b425788 --- /dev/null +++ b/api/internal/webhooks/email/outbound.ts @@ -0,0 +1,101 @@ +import { Server } from "@recommand/lib/api"; +import { describeRoute } from "hono-openapi"; +import { actionSuccess, actionFailure } from "@recommand/lib/utils"; +import { sendEmail } from "@core/lib/email"; +import { sendSystemAlert } from "@peppol/utils/system-notifications/telegram"; +import { sendDocumentFromEmail } from "@peppol/data/email/send-document-from-email"; +import { requirePostmarkWebhookAuth } from "@peppol/utils/auth-middleware"; +import { EmailToPeppolError } from "@peppol/emails/email-to-peppol-error"; + +const server = new Server(); + +interface PostmarkInboundEmail { + FromFull: { + Email: string; + Name: string; + }; + To: string; + Subject: string; + TextBody: string; + HtmlBody: string; + Attachments: Array<{ + Name: string; + Content: string; + ContentType: string; + ContentLength: number; + }>; + MessageID: string; + Date: string; +} + +server.post("/webhooks/email/outbound", requirePostmarkWebhookAuth(), describeRoute({ hide: true }), async (c) => { + try { + const inbound: PostmarkInboundEmail = await c.req.json(); + const toEmail = inbound.To.toLowerCase(); + const fromEmail = inbound.FromFull.Email; + + const xmlAttachment = inbound.Attachments.find( + (a) => + a.ContentType === "application/xml" || + a.ContentType === "text/xml" || + a.Name.toLowerCase().endsWith(".xml") + ); + + if (!xmlAttachment) { + await sendEmail({ + to: fromEmail, + subject: "Error: No XML attachment found", + email: EmailToPeppolError({ + error: "No XML attachment found", + hasXmlAttachment: false, + }), + }); + return c.json(actionSuccess({ error: "No XML attachment" })); + } + + const xmlContent = Buffer.from(xmlAttachment.Content, "base64").toString( + "utf-8" + ); + + const result = await sendDocumentFromEmail({ + toEmail, + fromEmail, + xmlContent, + }); + + if (result.success) { + return c.json( + actionSuccess({ + documentId: result.documentId, + company: result.company, + type: result.type, + recipient: result.recipient, + }) + ); + } else { + return c.json( + actionSuccess({ + error: result.error, + company: result.company, + details: result.details, + }) + ); + } + } catch (error) { + console.error("Email webhook error:", error); + sendSystemAlert( + "Email to Peppol Processing Failed", + `Failed to process email. Error: \`\`\`\n${error}\n\`\`\``, + "error" + ); + + return c.json( + actionFailure( + error instanceof Error ? error.message : "Failed to process email" + ), + 500 + ); + } +}); + +export default server; diff --git a/api/internal/webhooks/index.ts b/api/internal/webhooks/index.ts new file mode 100644 index 0000000..856c699 --- /dev/null +++ b/api/internal/webhooks/index.ts @@ -0,0 +1,7 @@ +import { Server } from "@recommand/lib/api"; +import emailServer from "./email"; + +const server = new Server(); +server.route("/", emailServer); + +export default server; diff --git a/app/(dashboard)/companies/[id]/page.tsx b/app/(dashboard)/companies/[id]/page.tsx index 9efbf8c..91367eb 100644 --- a/app/(dashboard)/companies/[id]/page.tsx +++ b/app/(dashboard)/companies/[id]/page.tsx @@ -13,6 +13,7 @@ import { CompanyForm } from "../../../../components/company-form"; import { CompanyIdentifiersManager } from "../../../../components/company-identifiers-manager"; import { CompanyDocumentTypesManager } from "../../../../components/company-document-types-manager"; import { CompanyNotificationsManager } from "../../../../components/company-notifications-manager"; +import { CompanyOutboundEmailManager } from "../../../../components/company-outbound-email-manager"; import { CompanyIntegrationsManager } from "../../../../components/company-integrations-manager"; import type { Company, CompanyFormData } from "../../../../types/company"; import { defaultCompanyFormData } from "../../../../types/company"; @@ -121,7 +122,6 @@ export default function CompanyDetailPage() { }); const json = await response.json(); - console.log("json", json); if (!json.success) { throw new Error(stringifyActionFailure(json.errors)); } @@ -267,6 +267,11 @@ export default function CompanyDetailPage() { teamId={activeTeam.id} companyId={company.id} /> + {canUseIntegrations(isPlayground, subscription) ? ( ("peppol"); + +function slugify(text: string): string { + return text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // Remove diacritics + .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens + .replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens + .substring(0, 30); // Limit length +} + +function generateRandomToken(length: number = 8): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +function generateEmailSlug(companyName: string): string { + const companySlug = slugify(companyName); + const token = generateRandomToken(); + return `${companySlug}-${token}`; +} + +type CompanyOutboundEmailManagerProps = { + teamId: string; + company: Company; + onUpdate?: () => void; +}; + +export function CompanyOutboundEmailManager({ + teamId, + company, + onUpdate, +}: CompanyOutboundEmailManagerProps) { + const [isEditing, setIsEditing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [slug, setSlug] = useState(company.outboundEmailSlug || ""); + const [enabled, setEnabled] = useState(company.outboundEmailEnabled); + const [copied, setCopied] = useState(false); + + const outboundEmailAddress = slug + ? `${slug}@out.recommand.eu` + : null; + + const handleCopy = async () => { + if (!outboundEmailAddress) return; + + try { + await navigator.clipboard.writeText(outboundEmailAddress); + setCopied(true); + toast.success("Email address copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + toast.error("Failed to copy to clipboard"); + } + }; + + const handleSave = async () => { + if (!slug.trim()) { + toast.error("Email slug is required"); + return; + } + + if (!/^[a-z0-9-]+$/.test(slug)) { + toast.error( + "Slug must contain only lowercase letters, numbers, and hyphens" + ); + return; + } + + try { + setIsSubmitting(true); + const response = await client[":teamId"]["companies"][":companyId"][ + "email"]["outbound" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + slug, + enabled, + }, + }); + + const json = await response.json(); + if (!json.success) { + throw new Error(stringifyActionFailure(json.errors)); + } + + toast.success("Outbound email settings updated successfully"); + setIsEditing(false); + if (onUpdate) onUpdate(); + } catch (error) { + toast.error("Failed to update outbound email settings: " + error); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + setSlug(company.outboundEmailSlug || ""); + setEnabled(company.outboundEmailEnabled); + setIsEditing(false); + }; + + const handleRegenerateSlug = () => { + setSlug(generateEmailSlug(company.name)); + }; + + const handleEnable = async () => { + if (!company.outboundEmailSlug) { + setSlug(generateEmailSlug(company.name)); + setIsEditing(true); + setEnabled(true); + return; + } + + try { + setIsSubmitting(true); + const response = await client[":teamId"]["companies"][":companyId"][ + "email"]["outbound" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + slug: company.outboundEmailSlug, + enabled: true, + }, + }); + + const json = await response.json(); + if (!json.success) { + throw new Error(stringifyActionFailure(json.errors)); + } + + toast.success("Email sending enabled"); + if (onUpdate) onUpdate(); + } catch (error) { + toast.error("Failed to enable email sending: " + error); + } finally { + setIsSubmitting(false); + } + }; + + const handleDisable = async () => { + try { + setIsSubmitting(true); + const response = await client[":teamId"]["companies"][":companyId"][ + "email"]["outbound" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + slug: company.outboundEmailSlug || "", + enabled: false, + }, + }); + + const json = await response.json(); + if (!json.success) { + throw new Error(stringifyActionFailure(json.errors)); + } + + toast.success("Email sending disabled"); + if (onUpdate) onUpdate(); + } catch (error) { + toast.error("Failed to disable email sending: " + error); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+
+ Email to Peppol + + Send documents over the Peppol network by forwarding emails with + XML attachments + +
+
+
+ + {!company.outboundEmailEnabled && !isEditing ? ( +
+ +
+

+ Email sending is not enabled for this company. +

+ +
+
+ ) : isEditing ? ( +
+
+ +
+ + +
+ @out.recommand.eu +
+
+

+ Only lowercase letters, numbers, and hyphens allowed +

+
+ + {slug && ( +
+

+ Your outbound email address will be: +

+ + {slug}@out.recommand.eu + +
+ )} + +
+ + Save Settings + + +
+
+ ) : ( +
+
+
+ +
+ + {outboundEmailAddress} + + +
+
+ +
+

How to use:

+
    +
  1. + Forward an email with a XML attachment to this address +
  2. +
  3. We automatically validate and send it over Peppol
  4. +
  5. + You receive a confirmation email when sent successfully +
  6. +
+
+
+ +
+ + + Disable + +
+
+ )} +
+
+ ); +} diff --git a/data/companies.ts b/data/companies.ts index df5f5f9..408dd4b 100644 --- a/data/companies.ts +++ b/data/companies.ts @@ -65,6 +65,24 @@ export async function getCompanyById( .then((rows) => rows[0]); } +export async function getCompanyByOutboundEmail( + email: string +): Promise { + const emailLower = email.toLowerCase(); + const slug = emailLower.split("@")[0]; + + return await db + .select() + .from(companies) + .where( + and( + eq(companies.outboundEmailSlug, slug), + eq(companies.outboundEmailEnabled, true) + ) + ) + .then((rows) => rows[0]); +} + /** * Get a company by its Peppol ID. When no playgroundTeamId is provided, the function will return a production company, otherwise it will return the company from the requested playground team. * @param peppolId The Peppol ID of the company diff --git a/data/email/send-document-from-email.ts b/data/email/send-document-from-email.ts new file mode 100644 index 0000000..57083de --- /dev/null +++ b/data/email/send-document-from-email.ts @@ -0,0 +1,299 @@ +import { sendEmail } from "@core/lib/email"; +import { db } from "@recommand/db"; +import { transferEvents, transmittedDocuments } from "@peppol/db/schema"; +import { + detectDoctypeId, + parseDocument, +} from "@peppol/utils/parsing/parse-document"; +import { sendAs4 } from "@peppol/data/phase4-ap/client"; +import { simulateSendAs4 } from "@peppol/data/playground/simulate-ap"; +import { getSendingCompanyIdentifier } from "@peppol/data/company-identifiers"; +import { sendOutgoingDocumentNotifications } from "@peppol/data/send-document-notifications"; +import { getDocumentTypeInfo } from "@peppol/utils/document-types"; +import { validateXmlDocument } from "@peppol/data/validation/client"; +import { getTeamExtension } from "@peppol/data/teams"; +import { getCompanyByOutboundEmail } from "@peppol/data/companies"; +import { ulid } from "ulid"; +import type { Invoice } from "@peppol/utils/parsing/invoice/schemas"; +import type { CreditNote } from "@peppol/utils/parsing/creditnote/schemas"; +import type { SelfBillingInvoice } from "@peppol/utils/parsing/self-billing-invoice/schemas"; +import type { SelfBillingCreditNote } from "@peppol/utils/parsing/self-billing-creditnote/schemas"; +import { EmailToPeppolError } from "@peppol/emails/email-to-peppol-error"; + +export interface SendDocumentFromEmailOptions { + toEmail: string; + fromEmail: string; + xmlContent: string; +} + +export interface SendDocumentFromEmailResult { + success: boolean; + documentId?: string; + company?: string; + type?: string; + recipient?: string; + error?: string; + details?: string; +} + +export async function sendDocumentFromEmail( + options: SendDocumentFromEmailOptions +): Promise { + const { toEmail, fromEmail, xmlContent } = options; + + const company = await getCompanyByOutboundEmail(toEmail); + if (!company) { + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Unknown recipient address", + details: `The email address ${toEmail} is not configured for document processing. Please check the email address and try again.`, + hasXmlAttachment: true, + }), + }); + return { success: false, error: "Unknown company" }; + } + + const teamExtension = await getTeamExtension(company.teamId); + const isPlayground = teamExtension?.isPlayground ?? false; + const useTestNetwork = teamExtension?.useTestNetwork ?? false; + + const doctypeId = detectDoctypeId(xmlContent); + if (!doctypeId) { + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Invalid document type", + details: "Document type could not be detected automatically. Please ensure you're sending a valid Peppol XML document.", + companyName: company.name, + hasXmlAttachment: true, + }), + }); + return { + success: false, + error: "Invalid document type", + company: company.name, + }; + } + + const validation = await validateXmlDocument(xmlContent); + if (validation.result === "invalid") { + const errorMessages = validation.errors + .map((e) => `${e.fieldName}: ${e.errorMessage}`) + .join("\n"); + + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Document validation failed", + details: errorMessages, + companyName: company.name, + hasXmlAttachment: true, + }), + }); + return { + success: false, + error: "Validation failed", + company: company.name, + }; + } + + const senderIdentifier = await getSendingCompanyIdentifier(company.id); + const senderAddress = `${senderIdentifier.scheme}:${senderIdentifier.identifier}`; + + const parsed = parseDocument(doctypeId, xmlContent, company, senderAddress); + const type = parsed.type; + const parsedDocument = parsed.parsedDocument; + + let recipientAddress = ""; + + if (type === "invoice" || type === "creditNote") { + const doc = parsedDocument as Invoice | CreditNote; + if ( + doc?.buyer?.endpointId?.schemeId && + doc?.buyer?.endpointId?.identifier + ) { + recipientAddress = `${doc.buyer.endpointId.schemeId}:${doc.buyer.endpointId.identifier}`; + } + } else if ( + type === "selfBillingInvoice" || + type === "selfBillingCreditNote" + ) { + const doc = parsedDocument as SelfBillingInvoice | SelfBillingCreditNote; + if ( + doc?.seller?.endpointId?.schemeId && + doc?.seller?.endpointId?.identifier + ) { + recipientAddress = `${doc.seller.endpointId.schemeId}:${doc.seller.endpointId.identifier}`; + } + } + + if (!recipientAddress) { + const partySection = type === "selfBillingInvoice" || type === "selfBillingCreditNote" + ? "AccountingSupplierParty" + : "AccountingCustomerParty"; + + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Recipient Peppol address not found", + details: `Please ensure the document includes the recipient's Peppol ID (EndpointID) in the ${partySection} section.`, + companyName: company.name, + hasXmlAttachment: true, + }), + }); + return { + success: false, + error: "No recipient address", + company: company.name, + }; + } + + let processId: string; + try { + processId = getDocumentTypeInfo(type).processId; + } catch (error) { + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Process ID detection failed", + details: `Document type detected: ${type}. Please contact support if this issue persists.`, + companyName: company.name, + hasXmlAttachment: true, + }), + }); + return { + success: false, + error: "Process ID detection failed", + company: company.name, + }; + } + + const transmittedDocumentId = "doc_" + ulid(); + + let sentPeppol = false; + let peppolMessageId: string | null = null; + let envelopeId: string | null = null; + let additionalContext = ""; + + if (isPlayground && !useTestNetwork) { + try { + await simulateSendAs4({ + senderId: senderAddress, + receiverId: recipientAddress, + docTypeId: doctypeId, + processId, + countryC1: company.country, + body: xmlContent, + playgroundTeamId: company.teamId, + }); + sentPeppol = true; + } catch (error) { + additionalContext = + error instanceof Error ? error.message : "Unknown error"; + } + } else { + const as4Response = await sendAs4({ + senderId: senderAddress, + receiverId: recipientAddress, + docTypeId: doctypeId, + processId, + countryC1: company.country, + body: xmlContent, + useTestNetwork, + }); + + if (!as4Response.ok) { + additionalContext = + as4Response.sendingException?.message ?? + "No additional context available"; + } else { + sentPeppol = true; + peppolMessageId = as4Response.peppolMessageId ?? null; + envelopeId = as4Response.sbdhInstanceIdentifier ?? null; + } + } + + if (!sentPeppol) { + await sendEmail({ + to: fromEmail, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Failed to send document over Peppol", + details: additionalContext, + companyName: company.name, + hasXmlAttachment: true, + }), + }); + return { + success: false, + error: "Peppol sending failed", + company: company.name, + details: additionalContext, + }; + } + + const transmittedDocument = await db + .insert(transmittedDocuments) + .values({ + id: transmittedDocumentId, + teamId: company.teamId, + companyId: company.id, + direction: "outgoing", + senderId: senderAddress, + receiverId: recipientAddress, + docTypeId: doctypeId, + processId, + countryC1: company.country, + xml: xmlContent, + sentOverPeppol: sentPeppol, + sentOverEmail: false, + emailRecipients: [], + type, + parsed: parsedDocument, + validation, + peppolMessageId, + peppolConversationId: null, + receivedPeppolSignalMessage: null, + envelopeId, + }) + .returning({ id: transmittedDocuments.id }) + .then((rows) => rows[0]); + + if (!isPlayground) { + await db.insert(transferEvents).values({ + teamId: company.teamId, + companyId: company.id, + direction: "outgoing", + type: "peppol", + transmittedDocumentId: transmittedDocument.id, + }); + } + + try { + await sendOutgoingDocumentNotifications({ + transmittedDocumentId: transmittedDocument.id, + companyId: company.id, + companyName: company.name, + type, + parsedDocument, + xmlDocument: xmlContent, + isPlayground, + }); + } catch (error) { + console.error("Failed to send outgoing document notifications:", error); + } + + return { + success: true, + documentId: transmittedDocument.id, + company: company.name, + type, + recipient: recipientAddress, + }; +} diff --git a/db/schema.ts b/db/schema.ts index 7630c85..b4345e5 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -243,6 +243,8 @@ export const companies = pgTable("peppol_companies", { enterpriseNumber: text("enterprise_number"), vatNumber: text("vat_number"), isSmpRecipient: boolean("is_smp_recipient").notNull().default(true), + outboundEmailSlug: text("outbound_email_slug").unique(), + outboundEmailEnabled: boolean("outbound_email_enabled").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), diff --git a/emails/email-to-peppol-error.tsx b/emails/email-to-peppol-error.tsx new file mode 100644 index 0000000..cc69f54 --- /dev/null +++ b/emails/email-to-peppol-error.tsx @@ -0,0 +1,73 @@ +import { Section, Text } from "@react-email/components"; +import { EmailLayout, EmailHeading } from "@core/emails/components/shared"; +import { DATA, ERROR, SHEET_LIGHT } from "@core/lib/config/colors"; + +interface EmailToPeppolErrorProps { + error: string; + details?: string; + companyName?: string; + hasXmlAttachment: boolean; +} + +export const EmailToPeppolError = ({ + error, + details, + companyName, + hasXmlAttachment, +}: EmailToPeppolErrorProps) => ( + + + {hasXmlAttachment ? "Error Processing Document" : "No XML Attachment Found"} + + + {!hasXmlAttachment ? ( + <> + + Your email was received, but no XML attachment was found. + +
+ What to do: + + • Attach an XML document (invoice, credit note, or self-billing) + + • Ensure the file has a .xml extension + • Send your email again to the same address +
+ + ) : ( + <> + + We encountered an error while processing your document + {companyName ? ` for company ${companyName}` : ""}. + +
+ Error: + {error} + {details && ( + <> + Details: + {details} + + )} +
+ + Please check your XML document and try again. If the problem persists, + contact our support team for assistance. + + + )} +
+); + +EmailToPeppolError.PreviewProps = { + error: "Invalid XML format", + details: "Missing required field: buyer party name", + companyName: "Acme Corporation", + hasXmlAttachment: true, +} as EmailToPeppolErrorProps; + +export default EmailToPeppolError; diff --git a/emails/integration-failure-notification.tsx b/emails/integration-failure-notification.tsx index 96c2a3c..3e62ed7 100644 --- a/emails/integration-failure-notification.tsx +++ b/emails/integration-failure-notification.tsx @@ -1,5 +1,6 @@ import { Section, Text } from "@react-email/components"; -import { EmailLayout, EmailHeading, InfoSection } from "@core/emails/components/shared"; +import { EmailLayout, EmailHeading } from "@core/emails/components/shared"; +import { ERROR, SHEET_LIGHT } from "@core/lib/config/colors"; import { getIntegrationEventDescription } from "@peppol/utils/integrations"; interface FailedTask { @@ -34,7 +35,9 @@ export const IntegrationFailureNotification = ({ {companyName} has failed during{" "} {eventName}. - +
Failed Tasks: {failedTasks.map((failedTask, index) => (
@@ -47,7 +50,7 @@ export const IntegrationFailureNotification = ({ )}
))} - +
Please review the integration configuration and ensure all required settings are correct. diff --git a/index.ts b/index.ts index 248b480..b13e0e9 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import sendDocumentServer from "./api/send-document"; import documentDefaultsServer from "./api/document-defaults"; import previewDocumentServer from "./api/preview-document"; import receiveDocumentServer from "./api/internal/receive-document"; +import internalWebhooksServer from "./api/internal/webhooks"; import transmittedDocumentsServer from "./api/documents"; import { generateSpecs, @@ -176,6 +177,7 @@ for (const prefix of ["/peppol/", "/v1/"]) { server.route(prefix, transmittedDocumentsServer); server.route(prefix, recipientServer); server.route(prefix + "internal/", receiveDocumentServer); + server.route(prefix + "internal/", internalWebhooksServer); server.route(prefix, webhooksServer); server.route(prefix, integrationsServer); diff --git a/package.json b/package.json index 5bba1a3..1652274 100644 --- a/package.json +++ b/package.json @@ -42,4 +42,4 @@ "zod": "^3.24.2", "zod-openapi": "^4.2.3" } -} \ No newline at end of file +} diff --git a/types/company.ts b/types/company.ts index b4918a3..2c6694e 100644 --- a/types/company.ts +++ b/types/company.ts @@ -1,19 +1,8 @@ -import { z } from "zod"; -import { zodValidCountryCodes } from "../db/schema"; +import type { CompanyResponse } from "../api/companies/shared"; -export type Company = { - id: string; - name: string; - address: string; - postalCode: string; - city: string; - country: z.infer; - enterpriseNumber: string | null; - vatNumber: string | null; - isSmpRecipient: boolean; -}; +export type Company = CompanyResponse; -export type CompanyFormData = Omit; +export type CompanyFormData = Omit; export const defaultCompanyFormData: CompanyFormData = { name: "", diff --git a/utils/auth-middleware.ts b/utils/auth-middleware.ts index c29288b..d4ea165 100644 --- a/utils/auth-middleware.ts +++ b/utils/auth-middleware.ts @@ -6,7 +6,10 @@ import { type AuthenticatedUserContext, type TeamAccessOptions, } from "@core/lib/auth-middleware"; -import { verifySession, type SessionVerificationExtension } from "@core/lib/session"; +import { + verifySession, + type SessionVerificationExtension, +} from "@core/lib/session"; import { getBillingProfile } from "@peppol/data/billing-profile"; import { getCompanyById, type Company } from "@peppol/data/companies"; import { verifyIntegrationJwt } from "@peppol/data/integrations/auth"; @@ -26,7 +29,11 @@ type InternalTokenContext = { export function requireInternalToken() { return createMiddleware(async (c, next) => { const token = c.req.header("X-Internal-Token"); - if (!token || (token !== process.env.INTERNAL_TOKEN && token !== process.env.INTERNAL_TEST_TOKEN)) { + if ( + !token || + (token !== process.env.INTERNAL_TOKEN && + token !== process.env.INTERNAL_TEST_TOKEN) + ) { return c.json(actionFailure("Unauthorized"), 401); } c.set("token", token); @@ -34,6 +41,29 @@ export function requireInternalToken() { }); } +export function requirePostmarkWebhookAuth() { + return createMiddleware(async (c, next) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Basic ")) { + return c.json(actionFailure("Unauthorized"), 401); + } + + const credentials = Buffer.from(authHeader.slice(6), "base64").toString( + "utf-8" + ); + const [username, password] = credentials.split(":"); + + if ( + username !== process.env.POSTMARK_INBOUND_WEBHOOK_USERNAME || + password !== process.env.POSTMARK_INBOUND_WEBHOOK_PASSWORD + ) { + return c.json(actionFailure("Unauthorized"), 401); + } + + await next(); + }); +} + export type CompanyAccessContext = { Variables: { company: Company; @@ -55,7 +85,6 @@ export function requireCompanyAccess(options: CompanyAccessOptions = {}) { return c.json(actionFailure("Unauthorized"), 401); } - const companyId = c.req.param("companyId"); if (!companyId) { return c.json(actionFailure("Company ID is required"), 400); @@ -103,12 +132,13 @@ export function requireCompanyAccess(options: CompanyAccessOptions = {}) { } export function requireValidSubscription() { - return createMiddleware( - async (c, next) => { - const team = c.var.team; - if (!team) { - return c.json(actionFailure("Team not found"), 404); - } + return createMiddleware< + AuthenticatedUserContext & AuthenticatedTeamContext & CompanyAccessContext + >(async (c, next) => { + const team = c.var.team; + if (!team) { + return c.json(actionFailure("Team not found"), 404); + } // Ensure the team has a valid billing profile if it's not a playground team if (!team.isPlayground) { @@ -131,9 +161,8 @@ export function requireValidSubscription() { } } - await next(); - } - ); + await next(); + }); } const integrationSupportedAuthExtensions: SessionVerificationExtension[] = [ @@ -153,21 +182,28 @@ const integrationSupportedAuthExtensions: SessionVerificationExtension[] = [ if (!payload) { return null; } - return { userId: null, isAdmin: false, apiKey: null, teamId: payload.teamId as string }; + return { + userId: null, + isAdmin: false, + apiKey: null, + teamId: payload.teamId as string, + }; } catch (error) { console.error(error); return null; } - } -] + }, +]; export function requireIntegrationSupportedAuth() { return requireAuth({ extensions: integrationSupportedAuthExtensions, - }) + }); } -export function requireIntegrationSupportedTeamAccess(options: TeamAccessOptions = {}) { +export function requireIntegrationSupportedTeamAccess( + options: TeamAccessOptions = {} +) { return requireTeamAccess({ ...options, extensions: integrationSupportedAuthExtensions, @@ -203,4 +239,4 @@ export function requireIntegrationAccess() { await next(); } ); -} \ No newline at end of file +} diff --git a/utils/parsing/creditnote/from-xml.ts b/utils/parsing/creditnote/from-xml.ts index 9ae6775..9055d96 100644 --- a/utils/parsing/creditnote/from-xml.ts +++ b/utils/parsing/creditnote/from-xml.ts @@ -1,6 +1,6 @@ import { XMLParser } from "fast-xml-parser"; import { creditNoteSchema, type CreditNote } from "./schemas"; -import { getTextContent, getNumberContent, getPercentage, getNullableTextContent, getNullableNumberContent } from "../xml-helpers"; +import { getTextContent, getNumberContent, getPercentage, getNullableTextContent, getNullableNumberContent, getEndpointId } from "../xml-helpers"; import type { SelfBillingCreditNote } from "../self-billing-creditnote/schemas"; import { getPaymentKeyByCode } from "@peppol/utils/payment-means"; @@ -60,6 +60,7 @@ export function parseCreditNoteFromXML(xml: string): CreditNote { } const seller = { + endpointId: getEndpointId(sellerParty.EndpointID), name: getNullableTextContent(sellerParty.PartyName?.Name) ?? getTextContent(sellerParty.PartyLegalEntity?.RegistrationName), street: getTextContent(sellerParty.PostalAddress?.StreetName), street2: getTextContent(sellerParty.PostalAddress?.AdditionalStreetName), @@ -78,6 +79,7 @@ export function parseCreditNoteFromXML(xml: string): CreditNote { } const buyer = { + endpointId: getEndpointId(buyerParty.EndpointID), name: getNullableTextContent(buyerParty.PartyName?.Name) ?? getTextContent(buyerParty.PartyLegalEntity?.RegistrationName), street: getTextContent(buyerParty.PostalAddress?.StreetName), street2: getTextContent(buyerParty.PostalAddress?.AdditionalStreetName), diff --git a/utils/parsing/invoice/from-xml.ts b/utils/parsing/invoice/from-xml.ts index 1acb2de..82e09ae 100644 --- a/utils/parsing/invoice/from-xml.ts +++ b/utils/parsing/invoice/from-xml.ts @@ -1,6 +1,6 @@ import { XMLParser } from "fast-xml-parser"; import { invoiceSchema, type Invoice } from "./schemas"; -import { getTextContent, getNumberContent, getPercentage, getNullableTextContent, getNullableNumberContent } from "../xml-helpers"; +import { getTextContent, getNumberContent, getPercentage, getNullableTextContent, getNullableNumberContent, getEndpointId } from "../xml-helpers"; import type { SelfBillingInvoice } from "../self-billing-invoice/schemas"; import { getPaymentKeyByCode } from "@peppol/utils/payment-means"; @@ -56,6 +56,7 @@ export function parseInvoiceFromXML(xml: string): Invoice & SelfBillingInvoice { } const seller = { + endpointId: getEndpointId(sellerParty.EndpointID), name: getNullableTextContent(sellerParty.PartyName?.Name) ?? getTextContent(sellerParty.PartyLegalEntity?.RegistrationName), street: getTextContent(sellerParty.PostalAddress?.StreetName), street2: getTextContent(sellerParty.PostalAddress?.AdditionalStreetName), @@ -74,6 +75,7 @@ export function parseInvoiceFromXML(xml: string): Invoice & SelfBillingInvoice { } const buyer = { + endpointId: getEndpointId(buyerParty.EndpointID), name: getNullableTextContent(buyerParty.PartyName?.Name) ?? getTextContent(buyerParty.PartyLegalEntity?.RegistrationName), street: getTextContent(buyerParty.PostalAddress?.StreetName), street2: getTextContent(buyerParty.PostalAddress?.AdditionalStreetName), diff --git a/utils/parsing/invoice/schemas.ts b/utils/parsing/invoice/schemas.ts index 7578798..cfd92ef 100644 --- a/utils/parsing/invoice/schemas.ts +++ b/utils/parsing/invoice/schemas.ts @@ -64,8 +64,18 @@ export const unlimitedDecimalSchema = z description: "Decimal number as a string with flexible precision", }); +export const endpointIdSchema = z + .object({ + schemeId: z.string().openapi({ example: "0208", description: "The scheme identifier (e.g., 0208 for Belgian Enterprise Number)" }), + identifier: z.string().openapi({ example: "0123456789", description: "The endpoint identifier value" }), + }) + .openapi({ ref: "EndpointId", description: "Peppol endpoint identifier" }); + export const partySchema = z .object({ + endpointId: endpointIdSchema.nullish().openapi({ + description: "The Peppol endpoint identifier of the party. Only present when parsing from XML.", + }), vatNumber: z.string().nullish().openapi({ example: "BE1234567894" }), enterpriseNumber: z.string().nullish().openapi({ example: "1234567894" }), name: z.string().openapi({ example: "Example Company" }), @@ -516,6 +526,7 @@ export const sendInvoiceSchema = _sendInvoiceSchema.openapi({ export type Invoice = z.infer; export type DocumentLine = z.infer; export type Party = z.infer; +export type EndpointId = z.infer; export type PaymentMeans = z.infer; export type PaymentTerms = z.infer; export type Item = z.infer; diff --git a/utils/parsing/message-level-response/from-xml.ts b/utils/parsing/message-level-response/from-xml.ts index 93e3d47..2040973 100644 --- a/utils/parsing/message-level-response/from-xml.ts +++ b/utils/parsing/message-level-response/from-xml.ts @@ -1,6 +1,6 @@ import { XMLParser } from "fast-xml-parser"; import { messageLevelResponseSchema, type MessageLevelResponse } from "./schemas"; -import { getTextContent } from "../xml-helpers"; +import { getTextContent, getEndpointId } from "../xml-helpers"; export function parseMessageLevelResponseFromXML(xml: string): MessageLevelResponse { const parser = new XMLParser({ @@ -20,5 +20,7 @@ export function parseMessageLevelResponseFromXML(xml: string): MessageLevelRespo issueDate: getTextContent(messageLevelResponse.IssueDate), responseCode: getTextContent(messageLevelResponse.DocumentResponse.Response.ResponseCode), envelopeId: getTextContent(messageLevelResponse.DocumentResponse.DocumentReference.ID), + senderEndpointId: getEndpointId(messageLevelResponse.SenderParty?.EndpointID), + receiverEndpointId: getEndpointId(messageLevelResponse.ReceiverParty?.EndpointID), }); } \ No newline at end of file diff --git a/utils/parsing/message-level-response/schemas.ts b/utils/parsing/message-level-response/schemas.ts index c38db71..2a7833e 100644 --- a/utils/parsing/message-level-response/schemas.ts +++ b/utils/parsing/message-level-response/schemas.ts @@ -1,4 +1,5 @@ import z from "zod"; +import { endpointIdSchema } from "../invoice/schemas"; export const responseCodeSchema = z.enum(["AB", "AP", "RE"]); @@ -7,6 +8,8 @@ export const messageLevelResponseSchema = z.object({ issueDate: z.string().date().openapi({ example: "2024-03-20" }), responseCode: responseCodeSchema.openapi({ description: "The response code of the message level response (AB: Message acknowledgement, AP: Accepted, RE: Rejected)", example: "AB" }), envelopeId: z.string().openapi({ description: "Identifies the document on which the message level response is based."}), + senderEndpointId: endpointIdSchema.nullish().openapi({ description: "The Peppol endpoint identifier of the sender. Only present when parsing from XML." }), + receiverEndpointId: endpointIdSchema.nullish().openapi({ description: "The Peppol endpoint identifier of the receiver. Only present when parsing from XML." }), }).openapi({ ref: "MessageLevelResponse", title: "Message Level Response", description: "Message Level Response received from a recipient" }); export const sendMessageLevelResponseSchema = messageLevelResponseSchema.extend({ diff --git a/utils/parsing/xml-helpers.ts b/utils/parsing/xml-helpers.ts index 17e2f97..f163456 100644 --- a/utils/parsing/xml-helpers.ts +++ b/utils/parsing/xml-helpers.ts @@ -36,4 +36,12 @@ export function getPercentage(value: any): string { // Remove any non-numeric characters except decimal point const cleaned = percentage.replace(/[^0-9.]/g, ''); return cleaned || "0"; +} + +export function getEndpointId(value: any): { schemeId: string; identifier: string } | null { + if (!value) return null; + const schemeId = value["@_schemeID"]; + const identifier = value["#text"] ?? value; + if (!schemeId || !identifier || typeof identifier !== "string") return null; + return { schemeId, identifier }; } \ No newline at end of file