From c62f1fea0b6e42c40b6318a89df7d149369b49ea Mon Sep 17 00:00:00 2001 From: kobeindemans Date: Sat, 10 Jan 2026 11:23:43 +0100 Subject: [PATCH 1/6] Added endpoint ID handling in XML parsing utilities and schemas --- utils/parsing/creditnote/from-xml.ts | 4 +++- utils/parsing/invoice/from-xml.ts | 4 +++- utils/parsing/invoice/schemas.ts | 11 +++++++++++ utils/parsing/message-level-response/from-xml.ts | 4 +++- utils/parsing/message-level-response/schemas.ts | 3 +++ utils/parsing/xml-helpers.ts | 8 ++++++++ 6 files changed, 31 insertions(+), 3 deletions(-) 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 From b1c942e8504e18c31a1b9f4ffd3b12884cf551a6 Mon Sep 17 00:00:00 2001 From: kobeindemans Date: Sat, 10 Jan 2026 11:27:29 +0100 Subject: [PATCH 2/6] Added email-to-Peppol sending feature --- .env.example | 6 +- api/companies/index.ts | 4 +- api/companies/send-email.ts | 73 +++++ api/inbound/email/index.ts | 7 + api/inbound/email/send.ts | 100 +++++++ api/inbound/index.ts | 7 + app/(dashboard)/companies/[id]/page.tsx | 26 +- components/company-send-email-manager.tsx | 337 ++++++++++++++++++++++ data/companies.ts | 18 ++ data/email/send-document-from-email.ts | 308 ++++++++++++++++++++ db/schema.ts | 2 + index.ts | 3 + types/company.ts | 7 +- utils/auth-middleware.ts | 72 +++-- 14 files changed, 943 insertions(+), 27 deletions(-) create mode 100644 api/companies/send-email.ts create mode 100644 api/inbound/email/index.ts create mode 100644 api/inbound/email/send.ts create mode 100644 api/inbound/index.ts create mode 100644 components/company-send-email-manager.tsx create mode 100644 data/email/send-document-from-email.ts 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/index.ts b/api/companies/index.ts index 1e9a128..92f391b 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 companySendEmailServer, { type CompanySendEmail } from "./send-email"; -export type Companies = GetCompanies | GetCompany | CreateCompany | UpdateCompany | DeleteCompany | CompanyIdentifiers | CompanyDocumentTypes | CompanyNotificationEmailAddresses; +export type Companies = GetCompanies | GetCompany | CreateCompany | UpdateCompany | DeleteCompany | CompanyIdentifiers | CompanyDocumentTypes | CompanyNotificationEmailAddresses | CompanySendEmail; 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("/", companySendEmailServer); export default server; \ No newline at end of file diff --git a/api/companies/send-email.ts b/api/companies/send-email.ts new file mode 100644 index 0000000..9cded2c --- /dev/null +++ b/api/companies/send-email.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 updateSendEmailSchema = z.object({ + sendEmailSlug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, "Slug must contain only lowercase letters, numbers, and hyphens"), + sendEmailEnabled: z.boolean(), +}); + +type UpdateSendEmailContext = Context< + AuthenticatedUserContext & AuthenticatedTeamContext & CompanyAccessContext, + string, + { + in: { json: z.input }; + out: { json: z.infer }; + } +>; + +const _updateSendEmail = server.put( + "/:teamId/companies/:companyId/send-email", + requireCompanyAccess(), + describeRoute({ + operationId: "updateCompanySendEmail", + summary: "Update company send email settings", + description: "Update the email-to-Peppol sending settings for a company", + tags: ["Companies"], + }), + zodValidator("json", updateSendEmailSchema), + async (c: UpdateSendEmailContext) => { + try { + const { sendEmailSlug, sendEmailEnabled } = c.req.valid("json"); + const company = c.var.company; + + const updatedCompany = await updateCompany({ + id: company.id, + teamId: company.teamId, + sendEmailSlug, + sendEmailEnabled, + }); + + const sendEmailAddress = sendEmailSlug + ? `${sendEmailSlug}@send.recommand.eu` + : null; + + return c.json( + actionSuccess({ + company: updatedCompany, + sendEmailAddress, + }) + ); + } catch (error) { + console.error("Error updating send email settings:", error); + return c.json( + actionFailure( + error instanceof Error ? error.message : "Failed to update send email settings" + ), + 400 + ); + } + } +); + +export type CompanySendEmail = typeof _updateSendEmail; + +export default server; diff --git a/api/inbound/email/index.ts b/api/inbound/email/index.ts new file mode 100644 index 0000000..b7d818d --- /dev/null +++ b/api/inbound/email/index.ts @@ -0,0 +1,7 @@ +import { Server } from "@recommand/lib/api"; +import sendServer from "./send"; + +const server = new Server(); +server.route("/", sendServer); + +export default server; diff --git a/api/inbound/email/send.ts b/api/inbound/email/send.ts new file mode 100644 index 0000000..94ab721 --- /dev/null +++ b/api/inbound/email/send.ts @@ -0,0 +1,100 @@ +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"; + +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("/inbound/email/send", 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: ` +

Your email to ${toEmail} was received, but no XML attachment was found.

+

Please attach an XML document (invoice, credit note, or self-billing) and try again.

+ `, + }); + 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/inbound/index.ts b/api/inbound/index.ts new file mode 100644 index 0000000..856c699 --- /dev/null +++ b/api/inbound/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..ff3003f 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 { CompanySendEmailManager } from "../../../../components/company-send-email-manager"; import { CompanyIntegrationsManager } from "../../../../components/company-integrations-manager"; import type { Company, CompanyFormData } from "../../../../types/company"; import { defaultCompanyFormData } from "../../../../types/company"; @@ -57,13 +58,17 @@ export default function CompanyDetailPage() { }); const json = await response.json(); - if (!json.success) { - toast.error(stringifyActionFailure(json.errors)); + if (!json.success || !json.company) { + toast.error(stringifyActionFailure(!json.success ? json.errors : {})); navigate("/companies"); return; } - const companyData = json.company as Company; + const companyData: Company = { + ...json.company, + createdAt: new Date(json.company.createdAt), + updatedAt: new Date(json.company.updatedAt), + } as Company; setCompany(companyData); setFormData(companyData); } catch (error) { @@ -122,11 +127,15 @@ export default function CompanyDetailPage() { const json = await response.json(); console.log("json", json); - if (!json.success) { - throw new Error(stringifyActionFailure(json.errors)); + if (!json.success || !json.company) { + throw new Error(stringifyActionFailure(!json.success ? json.errors : {})); } - const companyData = json.company as Company; + const companyData: Company = { + ...json.company, + createdAt: new Date(json.company.createdAt), + updatedAt: new Date(json.company.updatedAt), + } as Company; setCompany(companyData); setFormData(companyData); toast.success("Company updated successfully"); @@ -267,6 +276,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 CompanySendEmailManagerProps = { + teamId: string; + company: Company; + onUpdate?: () => void; +}; + +export function CompanySendEmailManager({ + teamId, + company, + onUpdate, +}: CompanySendEmailManagerProps) { + const [isEditing, setIsEditing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [sendEmailSlug, setSendEmailSlug] = useState( + company.sendEmailSlug || "" + ); + const [sendEmailEnabled, setSendEmailEnabled] = useState( + company.sendEmailEnabled + ); + const [copied, setCopied] = useState(false); + + const sendEmailAddress = sendEmailSlug + ? `${sendEmailSlug}@send.recommand.eu` + : null; + + const handleCopy = async () => { + if (!sendEmailAddress) return; + + try { + await navigator.clipboard.writeText(sendEmailAddress); + 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 (!sendEmailSlug.trim()) { + toast.error("Email slug is required"); + return; + } + + if (!/^[a-z0-9-]+$/.test(sendEmailSlug)) { + toast.error( + "Slug must contain only lowercase letters, numbers, and hyphens" + ); + return; + } + + try { + setIsSubmitting(true); + const response = await client[":teamId"]["companies"][":companyId"][ + "send-email" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + sendEmailSlug, + sendEmailEnabled, + }, + }); + + const json = await response.json(); + if (!json.success) { + throw new Error(stringifyActionFailure(json.errors)); + } + + toast.success("Send email settings updated successfully"); + setIsEditing(false); + if (onUpdate) onUpdate(); + } catch (error) { + toast.error("Failed to update send email settings: " + error); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + setSendEmailSlug(company.sendEmailSlug || ""); + setSendEmailEnabled(company.sendEmailEnabled); + setIsEditing(false); + }; + + const handleRegenerateSlug = () => { + setSendEmailSlug(generateEmailSlug(company.name)); + }; + + const handleEnable = async () => { + if (!company.sendEmailSlug) { + setSendEmailSlug(generateEmailSlug(company.name)); + setIsEditing(true); + setSendEmailEnabled(true); + return; + } + + try { + setIsSubmitting(true); + const response = await client[":teamId"]["companies"][":companyId"][ + "send-email" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + sendEmailSlug: company.sendEmailSlug, + sendEmailEnabled: 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"][ + "send-email" + ].$put({ + param: { teamId, companyId: company.id }, + json: { + sendEmailSlug: company.sendEmailSlug || "", + sendEmailEnabled: 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.sendEmailEnabled && !isEditing ? ( +
+ +
+

+ Email sending is not enabled for this company. +

+ +
+
+ ) : isEditing ? ( +
+
+ +
+ + setSendEmailSlug(e.target.value.toLowerCase()) + } + className="font-mono" + /> + +
+ @send.recommand.eu +
+
+

+ Only lowercase letters, numbers, and hyphens allowed +

+
+ + {sendEmailSlug && ( +
+

+ Your send email address will be: +

+ + {sendEmailSlug}@send.recommand.eu + +
+ )} + +
+ + Save Settings + + +
+
+ ) : ( +
+
+
+ +
+ + {sendEmailAddress} + + +
+
+ +
+

How to use:

+
    +
  1. Forward emails with XML attachments to this address
  2. +
  3. We automatically validate and send them over Peppol
  4. +
  5. You receive confirmation emails for each document
  6. +
+
+
+ +
+ + + Disable + +
+
+ )} +
+
+ ); +} diff --git a/data/companies.ts b/data/companies.ts index df5f5f9..082fdbc 100644 --- a/data/companies.ts +++ b/data/companies.ts @@ -65,6 +65,24 @@ export async function getCompanyById( .then((rows) => rows[0]); } +export async function getCompanyBySendEmail( + email: string +): Promise { + const emailLower = email.toLowerCase(); + const slug = emailLower.split("@")[0]; + + return await db + .select() + .from(companies) + .where( + and( + eq(companies.sendEmailSlug, slug), + eq(companies.sendEmailEnabled, 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..4e636b9 --- /dev/null +++ b/data/email/send-document-from-email.ts @@ -0,0 +1,308 @@ +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 { getCompanyBySendEmail } 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"; + +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 getCompanyBySendEmail(toEmail); + if (!company) { + await sendEmail({ + to: fromEmail, + subject: "Error: Unknown recipient address", + email: ` +

The email address ${toEmail} is not configured for document processing.

+

Please check the email address and try again, or contact support if you believe this is an error.

+ `, + }); + 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: Invalid XML document", + email: ` +

The XML document you sent to ${toEmail} could not be processed.

+

Document type could not be detected automatically. Please ensure you're sending a valid Peppol XML document.

+ `, + }); + 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("
"); + + await sendEmail({ + to: fromEmail, + subject: "Error: Document validation failed", + email: ` +

The XML document you sent to ${toEmail} failed validation:

+
${errorMessages}
+

Please fix the errors and try again.

+ `, + }); + 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) { + await sendEmail({ + to: fromEmail, + subject: "Error: Recipient Peppol address not found", + email: ` +

The XML document you sent to ${toEmail} does not contain a valid recipient Peppol address.

+

Please ensure the document includes the recipient's Peppol ID (EndpointID) in the ${ + type === "selfBillingInvoice" || type === "selfBillingCreditNote" + ? "AccountingSupplierParty" + : "AccountingCustomerParty" + } section.

+ `, + }); + 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: Could not determine process ID", + email: ` +

Failed to determine the process ID for your document sent to ${toEmail}.

+

Document type detected: ${type}

+

Please contact support if this issue persists.

+ `, + }); + 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: Failed to send document over Peppol", + email: ` +

Your document sent to ${toEmail} could not be sent over the Peppol network.

+

Error: ${additionalContext}

+

Please contact support if this issue persists.

+ `, + }); + 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); + } + + await sendEmail({ + to: fromEmail, + subject: `Document sent successfully via ${company.name}`, + email: ` +

Your document has been successfully sent over the Peppol network.

+

Details:

+
    +
  • Document ID: ${transmittedDocument.id}
  • +
  • Company: ${company.name}
  • +
  • Document Type: ${type}
  • +
  • Recipient: ${recipientAddress}
  • + ${peppolMessageId ? `
  • Peppol Message ID: ${peppolMessageId}
  • ` : ""} + ${envelopeId ? `
  • Envelope ID: ${envelopeId}
  • ` : ""} +
+

Thank you for using Recommand.

+ `, + }); + + return { + success: true, + documentId: transmittedDocument.id, + company: company.name, + type, + recipient: recipientAddress, + }; +} diff --git a/db/schema.ts b/db/schema.ts index 7630c85..4120032 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), + sendEmailSlug: text("send_email_slug").unique(), + sendEmailEnabled: boolean("send_email_enabled").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), diff --git a/index.ts b/index.ts index 248b480..86aceed 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 inboundServer from "./api/inbound"; import transmittedDocumentsServer from "./api/documents"; import { generateSpecs, @@ -188,6 +189,8 @@ for (const prefix of ["/peppol/", "/v1/"]) { server.route(prefix, billingProfileServer); server.route(prefix, billingServer); server.route(prefix, subscriptionServer); + + server.route(prefix, inboundServer); } export default server; diff --git a/types/company.ts b/types/company.ts index b4918a3..bc48031 100644 --- a/types/company.ts +++ b/types/company.ts @@ -11,9 +11,14 @@ export type Company = { enterpriseNumber: string | null; vatNumber: string | null; isSmpRecipient: boolean; + sendEmailSlug: string | null; + sendEmailEnabled: boolean; + teamId: string; + createdAt: Date; + updatedAt: Date; }; -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 +} From cdaecb29f8e138e51be60d68af87ae11dbe46be6 Mon Sep 17 00:00:00 2001 From: kobeindemans Date: Sat, 10 Jan 2026 19:52:34 +0100 Subject: [PATCH 3/6] Enhanced email notifications for email-to-peppol functionality --- api/inbound/email/send.ts | 9 +- components/company-send-email-manager.tsx | 10 +- data/email/send-document-from-email.ts | 103 +++++++++----------- emails/email-to-peppol-error.tsx | 113 ++++++++++++++++++++++ package.json | 2 +- 5 files changed, 173 insertions(+), 64 deletions(-) create mode 100644 emails/email-to-peppol-error.tsx diff --git a/api/inbound/email/send.ts b/api/inbound/email/send.ts index 94ab721..02eae22 100644 --- a/api/inbound/email/send.ts +++ b/api/inbound/email/send.ts @@ -5,6 +5,7 @@ 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(); @@ -44,10 +45,10 @@ server.post("/inbound/email/send", requirePostmarkWebhookAuth(), describeRoute({ await sendEmail({ to: fromEmail, subject: "Error: No XML attachment found", - email: ` -

Your email to ${toEmail} was received, but no XML attachment was found.

-

Please attach an XML document (invoice, credit note, or self-billing) and try again.

- `, + email: EmailToPeppolError({ + error: "No XML attachment found", + hasXmlAttachment: false, + }), }); return c.json(actionSuccess({ error: "No XML attachment" })); } diff --git a/components/company-send-email-manager.tsx b/components/company-send-email-manager.tsx index f53b1a5..2d587ea 100644 --- a/components/company-send-email-manager.tsx +++ b/components/company-send-email-manager.tsx @@ -305,9 +305,13 @@ export function CompanySendEmailManager({

How to use:

    -
  1. Forward emails with XML attachments to this address
  2. -
  3. We automatically validate and send them over Peppol
  4. -
  5. You receive confirmation emails for each document
  6. +
  7. + Forward an email with a XML attachment to this address +
  8. +
  9. We automatically validate and send it over Peppol
  10. +
  11. + You receive a confirmation email when sent successfully +
diff --git a/data/email/send-document-from-email.ts b/data/email/send-document-from-email.ts index 4e636b9..8a7c13b 100644 --- a/data/email/send-document-from-email.ts +++ b/data/email/send-document-from-email.ts @@ -18,6 +18,7 @@ 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; @@ -44,11 +45,12 @@ export async function sendDocumentFromEmail( if (!company) { await sendEmail({ to: fromEmail, - subject: "Error: Unknown recipient address", - email: ` -

The email address ${toEmail} is not configured for document processing.

-

Please check the email address and try again, or contact support if you believe this is an error.

- `, + 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" }; } @@ -61,11 +63,13 @@ export async function sendDocumentFromEmail( if (!doctypeId) { await sendEmail({ to: fromEmail, - subject: "Error: Invalid XML document", - email: ` -

The XML document you sent to ${toEmail} could not be processed.

-

Document type could not be detected automatically. Please ensure you're sending a valid Peppol XML document.

- `, + 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, @@ -78,16 +82,17 @@ export async function sendDocumentFromEmail( if (validation.result === "invalid") { const errorMessages = validation.errors .map((e) => `${e.fieldName}: ${e.errorMessage}`) - .join("
"); + .join("\n"); await sendEmail({ to: fromEmail, - subject: "Error: Document validation failed", - email: ` -

The XML document you sent to ${toEmail} failed validation:

-
${errorMessages}
-

Please fix the errors and try again.

- `, + subject: "Error processing your Peppol document", + email: EmailToPeppolError({ + error: "Document validation failed", + details: errorMessages, + companyName: company.name, + hasXmlAttachment: true, + }), }); return { success: false, @@ -127,17 +132,19 @@ export async function sendDocumentFromEmail( } if (!recipientAddress) { + const partySection = type === "selfBillingInvoice" || type === "selfBillingCreditNote" + ? "AccountingSupplierParty" + : "AccountingCustomerParty"; + await sendEmail({ to: fromEmail, - subject: "Error: Recipient Peppol address not found", - email: ` -

The XML document you sent to ${toEmail} does not contain a valid recipient Peppol address.

-

Please ensure the document includes the recipient's Peppol ID (EndpointID) in the ${ - type === "selfBillingInvoice" || type === "selfBillingCreditNote" - ? "AccountingSupplierParty" - : "AccountingCustomerParty" - } section.

- `, + 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, @@ -152,12 +159,13 @@ export async function sendDocumentFromEmail( } catch (error) { await sendEmail({ to: fromEmail, - subject: "Error: Could not determine process ID", - email: ` -

Failed to determine the process ID for your document sent to ${toEmail}.

-

Document type detected: ${type}

-

Please contact support if this issue persists.

- `, + 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, @@ -214,12 +222,13 @@ export async function sendDocumentFromEmail( if (!sentPeppol) { await sendEmail({ to: fromEmail, - subject: "Error: Failed to send document over Peppol", - email: ` -

Your document sent to ${toEmail} could not be sent over the Peppol network.

-

Error: ${additionalContext}

-

Please contact support if this issue persists.

- `, + 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, @@ -280,24 +289,6 @@ export async function sendDocumentFromEmail( console.error("Failed to send outgoing document notifications:", error); } - await sendEmail({ - to: fromEmail, - subject: `Document sent successfully via ${company.name}`, - email: ` -

Your document has been successfully sent over the Peppol network.

-

Details:

-
    -
  • Document ID: ${transmittedDocument.id}
  • -
  • Company: ${company.name}
  • -
  • Document Type: ${type}
  • -
  • Recipient: ${recipientAddress}
  • - ${peppolMessageId ? `
  • Peppol Message ID: ${peppolMessageId}
  • ` : ""} - ${envelopeId ? `
  • Envelope ID: ${envelopeId}
  • ` : ""} -
-

Thank you for using Recommand.

- `, - }); - return { success: true, documentId: transmittedDocument.id, diff --git a/emails/email-to-peppol-error.tsx b/emails/email-to-peppol-error.tsx new file mode 100644 index 0000000..f13c977 --- /dev/null +++ b/emails/email-to-peppol-error.tsx @@ -0,0 +1,113 @@ +import { + Body, + Container, + Heading, + Html, + Preview, + Section, + Text, + Tailwind, + Img, +} from "@react-email/components"; +import { Head } from "@core/emails/components/head"; +import { SHADOW, DARK_SLATE, SHEET } from "@core/lib/config/colors"; + +interface EmailToPeppolErrorProps { + error: string; + details?: string; + companyName?: string; + hasXmlAttachment: boolean; +} + +export const EmailToPeppolError = ({ + error, + details, + companyName, + hasXmlAttachment, +}: EmailToPeppolErrorProps) => { + return ( + + + Error processing your Peppol document + + + + Recommand Logo + + {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. + + + )} + + + Best regards, +
+ The Recommand Team +
+
+ +
+ + ); +}; + +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/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 +} From bb9718ac8446f2e54c5a53d8502d03b10a98fbec Mon Sep 17 00:00:00 2001 From: kobeindemans Date: Sun, 11 Jan 2026 11:46:18 +0100 Subject: [PATCH 4/6] Make sendEmailSlug input read-only in CompanySendEmailManager --- components/company-send-email-manager.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/company-send-email-manager.tsx b/components/company-send-email-manager.tsx index 2d587ea..acbf2cd 100644 --- a/components/company-send-email-manager.tsx +++ b/components/company-send-email-manager.tsx @@ -229,9 +229,7 @@ export function CompanySendEmailManager({ type="text" placeholder="your-company" value={sendEmailSlug} - onChange={(e) => - setSendEmailSlug(e.target.value.toLowerCase()) - } + readOnly className="font-mono" />
- @send.recommand.eu + @out.recommand.eu

@@ -250,13 +246,13 @@ export function CompanySendEmailManager({

- {sendEmailSlug && ( + {slug && (

- Your send email address will be: + Your outbound email address will be:

- {sendEmailSlug}@send.recommand.eu + {slug}@out.recommand.eu
)} @@ -279,11 +275,11 @@ export function CompanySendEmailManager({
- {sendEmailAddress} + {outboundEmailAddress}