snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
393
supabase/functions/postmark/index.ts
Normal file
393
supabase/functions/postmark/index.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
// Follow this setup guide to integrate the Deno language server with your editor:
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { addNoteToContact, findActiveSaleByEmail } from "./addNoteToContact.ts";
|
||||
import {
|
||||
getForwardedMailContent,
|
||||
stripSubjectForwardingPrefix,
|
||||
} from "./forwardedParser.ts";
|
||||
import { extractMailContactData } from "./extractMailContactData.ts";
|
||||
import { getExpectedAuthorization } from "./getExpectedAuthorization.ts";
|
||||
import { getNoteContent } from "./getNoteContent.ts";
|
||||
import {
|
||||
extractAndUploadAttachments,
|
||||
removeUploadedAttachments,
|
||||
} from "./extractAndUploadAttachments.ts";
|
||||
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
|
||||
|
||||
const webhookUser = Deno.env.get("POSTMARK_WEBHOOK_USER");
|
||||
const webhookPassword = Deno.env.get("POSTMARK_WEBHOOK_PASSWORD");
|
||||
const INBOUND_EMAIL = (Deno.env.get("VITE_INBOUND_EMAIL") || "").toLowerCase();
|
||||
if (!webhookUser || !webhookPassword) {
|
||||
throw new Error(
|
||||
"Missing POSTMARK_WEBHOOK_USER or POSTMARK_WEBHOOK_PASSWORD env variable",
|
||||
);
|
||||
}
|
||||
|
||||
const rawAuthorizedIPs = Deno.env.get("POSTMARK_WEBHOOK_AUTHORIZED_IPS");
|
||||
if (!rawAuthorizedIPs) {
|
||||
throw new Error("Missing POSTMARK_WEBHOOK_AUTHORIZED_IPS env variable");
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
let response: Response | undefined;
|
||||
|
||||
response = checkRequestTypeAndHeaders(req);
|
||||
if (response) return response;
|
||||
|
||||
const json = await req.json();
|
||||
response = checkBody(json);
|
||||
if (response) return response;
|
||||
|
||||
const { FromFull, Attachments } = json;
|
||||
let { ToFull, TextBody, Subject } = json;
|
||||
|
||||
const salesEmail = (FromFull.Email || "").toLowerCase();
|
||||
if (!salesEmail) {
|
||||
// Return a 403 to let Postmark know that it's no use to retry this request
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
return new Response(
|
||||
`Could not extract sales email from FromFull: ${FromFull}`,
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const allSales = await supabaseAdmin
|
||||
.from("sales")
|
||||
.select("email, secondary_emails");
|
||||
|
||||
if (allSales.error) {
|
||||
console.error("Could not fetch sales emails:", allSales.error);
|
||||
return new Response("Could not fetch sales from database", { status: 500 });
|
||||
}
|
||||
|
||||
const salesEmails =
|
||||
allSales.data
|
||||
?.flatMap((s: { email: string; secondary_emails: string[] }) => [
|
||||
s.email,
|
||||
...s.secondary_emails,
|
||||
])
|
||||
.map((email: string) => email.toLowerCase()) ?? [];
|
||||
|
||||
const firstToEmail = (ToFull[0]?.Email || "").toLowerCase();
|
||||
|
||||
// If we have an INBOUND_EMAIL and the email is sent only to the inbound email address, and the sender is a known sales email,
|
||||
// then we can try to extract the real recipient email from the body of the email
|
||||
if (
|
||||
INBOUND_EMAIL &&
|
||||
ToFull.length === 1 &&
|
||||
firstToEmail === INBOUND_EMAIL &&
|
||||
salesEmails.includes(salesEmail)
|
||||
) {
|
||||
const emailRegex = /[\w.+%-]+@[\w.-]+\.[a-zA-Z]{2,}/g;
|
||||
const emailsInBody = TextBody.match(emailRegex) || [];
|
||||
|
||||
const candidateEmails = emailsInBody
|
||||
.map((email: string) => email.toLowerCase())
|
||||
.filter(
|
||||
(email: string) =>
|
||||
email !== INBOUND_EMAIL && !salesEmails?.includes(email),
|
||||
);
|
||||
if (candidateEmails.length > 0) {
|
||||
ToFull = [
|
||||
{
|
||||
Email: candidateEmails[0],
|
||||
Name: "",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Return a 403 to let Postmark know that it's no use to retry this request
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
return new Response(
|
||||
`Could not extract recipient email from transferred email body.`,
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
TextBody = getForwardedMailContent(TextBody);
|
||||
Subject = stripSubjectForwardingPrefix(Subject);
|
||||
}
|
||||
|
||||
const noteContent = getNoteContent(Subject, TextBody);
|
||||
|
||||
const contacts = extractMailContactData(ToFull);
|
||||
|
||||
const { data: sales, error: fetchSalesError } =
|
||||
await findActiveSaleByEmail(salesEmail);
|
||||
|
||||
if (fetchSalesError) {
|
||||
console.error("Could not fetch the sender's sale:", fetchSalesError);
|
||||
return new Response(
|
||||
`Could not fetch sales from database, email: ${salesEmail}`,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sales) {
|
||||
// Return a 403 to let Postmark know that it's no use to retry this request
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
return new Response(
|
||||
`Unable to find (active) sales in database, email: ${salesEmail}`,
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const attachments = await extractAndUploadAttachments(Attachments);
|
||||
|
||||
const failedContacts: string[] = [];
|
||||
|
||||
for (const {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
domain,
|
||||
companyName,
|
||||
website,
|
||||
} of contacts) {
|
||||
if (!email) {
|
||||
// Return a 403 to let Postmark know that it's no use to retry this request
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
return new Response(`Could not extract email from ToFull: ${ToFull}`, {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const errorResponse = await addNoteToContact({
|
||||
sales,
|
||||
salesEmail,
|
||||
email,
|
||||
domain,
|
||||
firstName,
|
||||
lastName,
|
||||
noteContent,
|
||||
attachments,
|
||||
companyName,
|
||||
website,
|
||||
});
|
||||
|
||||
if (errorResponse) {
|
||||
console.error(
|
||||
`Could not add the note for ${email}: ${await errorResponse.text()}`,
|
||||
);
|
||||
failedContacts.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedContacts.length) {
|
||||
// A partial failure answers 403 so Postmark does not redeliver: the notes
|
||||
// already written are not idempotent and would be duplicated. When every
|
||||
// contact failed there is no note to duplicate, so a retry is safe.
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
const status = failedContacts.length === contacts.length ? 500 : 403;
|
||||
if (status === 500) {
|
||||
await removeUploadedAttachments(attachments);
|
||||
}
|
||||
return new Response(
|
||||
`Could not add the note for: ${failedContacts.join(", ")}`,
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("OK");
|
||||
});
|
||||
|
||||
const checkRequestTypeAndHeaders = (req: Request) => {
|
||||
// Only allow known IP addresses
|
||||
// We can use the x-forwarded-for header as it is populated by Supabase
|
||||
// https://supabase.com/docs/guides/api/securing-your-api#accessing-request-information
|
||||
const forwardedFor = req.headers.get("x-forwarded-for");
|
||||
if (!forwardedFor) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const ips = forwardedFor.split(",").map((ip) => ip.trim());
|
||||
const authorizedIPs = rawAuthorizedIPs
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim());
|
||||
if (!ips.some((ip) => authorizedIPs.includes(ip))) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Only allow POST requests
|
||||
if (req.method !== "POST") {
|
||||
return new Response(null, { status: 405 });
|
||||
}
|
||||
|
||||
// Check the Authorization header
|
||||
const expectedAuthorization = getExpectedAuthorization(
|
||||
webhookUser,
|
||||
webhookPassword,
|
||||
);
|
||||
const authorization = req.headers.get("Authorization");
|
||||
if (authorization !== expectedAuthorization) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
};
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const checkBody = (json: any) => {
|
||||
const { ToFull, FromFull, Subject, TextBody } = json;
|
||||
|
||||
// In case of incorrect request data, we
|
||||
// return a 403 to let Postmark know that it's no use to retry this request
|
||||
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
|
||||
if (!ToFull || !ToFull.length)
|
||||
return new Response("Missing parameter: ToFull", { status: 403 });
|
||||
if (!FromFull)
|
||||
return new Response("Missing parameter: FromFull", { status: 403 });
|
||||
if (!Subject)
|
||||
return new Response("Missing parameter: Subject", { status: 403 });
|
||||
if (!TextBody)
|
||||
return new Response("Missing parameter: TextBody", {
|
||||
status: 403,
|
||||
});
|
||||
};
|
||||
|
||||
/* To invoke locally:
|
||||
1. Run `make start`
|
||||
2. Make sure to have a Sales with email "support@postmarkapp.com" (create it if needed)
|
||||
3. OPTIONAL: Create a Contact with email "firstname.lastname@marmelab.com"
|
||||
4. In another terminal, run `make start-supabase-functions`
|
||||
5. In another terminal, make an HTTP request:
|
||||
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/postmark' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Authorization: Basic dGVzdHVzZXI6dGVzdHB3ZA==' \
|
||||
--data '{
|
||||
"FromName": "Postmarkapp Support",
|
||||
"From": "support@postmarkapp.com",
|
||||
"FromFull": {
|
||||
"Email": "support@postmarkapp.com",
|
||||
"Name": "Postmarkapp Support",
|
||||
"MailboxHash": ""
|
||||
},
|
||||
"To": "\"Firstname Lastname\" <firstname.lastname@marmelab.com>",
|
||||
"ToFull": [
|
||||
{
|
||||
"Email": "firstname.lastname@marmelab.com",
|
||||
"Name": "Firstname Lastname",
|
||||
"MailboxHash": "SampleHash"
|
||||
}
|
||||
],
|
||||
"Cc": "\"First Cc\" <firstcc@postmarkapp.com>, secondCc@postmarkapp.com",
|
||||
"CcFull": [
|
||||
{
|
||||
"Email": "firstcc@postmarkapp.com",
|
||||
"Name": "First Cc",
|
||||
"MailboxHash": ""
|
||||
},
|
||||
{
|
||||
"Email": "secondCc@postmarkapp.com",
|
||||
"Name": "",
|
||||
"MailboxHash": ""
|
||||
}
|
||||
],
|
||||
"Bcc": "\"First Bcc\" <firstbcc@postmarkapp.com>, secondbcc@postmarkapp.com",
|
||||
"BccFull": [
|
||||
{
|
||||
"Email": "firstbcc@postmarkapp.com",
|
||||
"Name": "First Bcc",
|
||||
"MailboxHash": ""
|
||||
},
|
||||
{
|
||||
"Email": "secondbcc@postmarkapp.com",
|
||||
"Name": "",
|
||||
"MailboxHash": ""
|
||||
}
|
||||
],
|
||||
"OriginalRecipient": "firstname.lastname@marmelab.com",
|
||||
"Subject": "Test subject",
|
||||
"MessageID": "73e6d360-66eb-11e1-8e72-a8904824019b",
|
||||
"ReplyTo": "replyto@postmarkapp.com",
|
||||
"MailboxHash": "SampleHash",
|
||||
"Date": "Fri, 1 Aug 2014 16:45:32 -04:00",
|
||||
"TextBody": "This is a test text body.",
|
||||
"HtmlBody": "<html><body><p>This is a test html body.</p></body></html>",
|
||||
"StrippedTextReply": "This is the reply text",
|
||||
"Tag": "TestTag",
|
||||
"Headers": [
|
||||
{
|
||||
"Name": "X-Header-Test",
|
||||
"Value": ""
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Status",
|
||||
"Value": "No"
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Score",
|
||||
"Value": "-0.1"
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Tests",
|
||||
"Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS"
|
||||
}
|
||||
],
|
||||
"Attachments": [
|
||||
{
|
||||
"Name": "test.txt",
|
||||
"Content": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
|
||||
"ContentType": "text/plain",
|
||||
"ContentLength": 45
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
|
||||
To trigger the email forwarding feature, you can change the "To" and "ToFull" fields to have the INBOUND_EMAIL, and add an email address that is neither a sales nor the INBOUND_EMAIL, for example:
|
||||
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/postmark' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Authorization: Basic dGVzdHVzZXI6dGVzdHB3ZA==' \
|
||||
--data '{
|
||||
"FromName": "Postmarkapp Support",
|
||||
"MessageStream": "inbound",
|
||||
"From": "support@postmarkapp.com",
|
||||
"FromFull": {
|
||||
"Email": "support@postmarkapp.com",
|
||||
"Name": "Postmarkapp Support",
|
||||
"MailboxHash": ""
|
||||
},
|
||||
"To": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
|
||||
"ToFull": [
|
||||
{
|
||||
"Email": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
|
||||
"Name": "",
|
||||
"MailboxHash": ""
|
||||
}
|
||||
],
|
||||
"Cc": "",
|
||||
"CcFull": [],
|
||||
"Bcc": "",
|
||||
"BccFull": [],
|
||||
"OriginalRecipient": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
|
||||
"Subject": "Fwd: Test for forwarding mail",
|
||||
"MessageID": "32dcbecb-57d0-476e-9591-c747808cb599",
|
||||
"ReplyTo": "",
|
||||
"MailboxHash": "",
|
||||
"Date": "Thu, 5 Mar 2026 10:41:26 +0100",
|
||||
"TextBody": "---------- Forwarded message ---------\nFrom : Original Recipient <original.recipient@company.com>\nDate: Fri, 1 Aug 2014 16:45:32 -04:00\nSubject: Test for forwarding mail\nTo: Postmarkapp Support <support@postmarkapp.com>\n\n\nThe transferred message body\n",
|
||||
"HtmlBody": "<div dir=\"ltr\"><br><br><div class=\"gmail_quote gmail_quote_container\"><div dir=\"ltr\" class=\"gmail_attr\">---------- Forwarded message ---------<br>From: <strong class=\"gmail_sendername\" dir=\"auto\">Original Recipient</strong> <span dir=\"auto\"><<a href=\"mailto:original.recipient@company.com\">original.recipient@company.com</a>></span><br>Date: Fri, 1 Aug 2014 16:45:32 -04:00<br>Subject: Test for forwarding mail<br>To: Postmarkapp Support <<a href=\"mailto:support@postmarkapp.com\">support@postmarkapp.com</a>><br></div><br><br><div dir=\"ltr\">The transferred message body</div>\n</div></div>\n",
|
||||
"StrippedTextReply": "",
|
||||
"Tag": "",
|
||||
"Headers": [
|
||||
{
|
||||
"Name": "X-Header-Test",
|
||||
"Value": ""
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Status",
|
||||
"Value": "No"
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Score",
|
||||
"Value": "-0.1"
|
||||
},
|
||||
{
|
||||
"Name": "X-Spam-Tests",
|
||||
"Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS"
|
||||
}
|
||||
],
|
||||
"Attachments": []
|
||||
}'
|
||||
|
||||
*/
|
||||
Reference in New Issue
Block a user