Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
BRBX_BILLING_LIVE_JWT=

# Postmark inbound
POSTMARK_INBOUND_WEBHOOK_USERNAME=
POSTMARK_INBOUND_WEBHOOK_PASSWORD=
73 changes: 73 additions & 0 deletions api/companies/email/outbound.ts
Original file line number Diff line number Diff line change
@@ -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<typeof updateOutboundEmailSchema> };
out: { json: z.infer<typeof updateOutboundEmailSchema> };
}
>;

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;
4 changes: 3 additions & 1 deletion api/companies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -20,4 +21,5 @@ server.route("/", deleteCompanyServer);
server.route("/", companyIdentifiersServer);
server.route("/", companyDocumentTypesServer);
server.route("/", companyNotificationEmailAddressesServer);
server.route("/", companyEmailOutboundServer);
export default server;
10 changes: 7 additions & 3 deletions api/companies/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
});

export type CompanyResponse = z.infer<typeof companyResponse>;
7 changes: 7 additions & 0 deletions api/internal/webhooks/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Server } from "@recommand/lib/api";
import outboundServer from "./outbound";

const server = new Server();
server.route("/", outboundServer);

export default server;
101 changes: 101 additions & 0 deletions api/internal/webhooks/email/outbound.ts
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions api/internal/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Server } from "@recommand/lib/api";
import emailServer from "./email";

const server = new Server();
server.route("/", emailServer);

export default server;
7 changes: 6 additions & 1 deletion app/(dashboard)/companies/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -267,6 +267,11 @@ export default function CompanyDetailPage() {
teamId={activeTeam.id}
companyId={company.id}
/>
<CompanyOutboundEmailManager
teamId={activeTeam.id}
company={company}
onUpdate={fetchCompany}
/>
{canUseIntegrations(isPlayground, subscription) ? (
<CompanyIntegrationsManager
teamId={activeTeam.id}
Expand Down
Loading