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

This commit is contained in:
upstream-mirror
2026-09-18 01:48:50 +05:00
commit 93ecc1a6f8
787 changed files with 75240 additions and 0 deletions

View File

@@ -0,0 +1,647 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
addNoteToContact,
findActiveSaleByEmail,
getOrCreateCompanyFromDomain,
getOrCreateContactFromEmailInfo,
} from "./addNoteToContact";
const mockFrom = vi.hoisted(() => vi.fn());
vi.mock("../_shared/supabaseAdmin.ts", () => ({
supabaseAdmin: {
from: (...args: unknown[]) => mockFrom(...args),
},
}));
const secondaryLookupResult = (
rows: unknown[],
error: { message: string } | null = null,
) => ({
neq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: error ? null : rows, error }),
}),
}),
});
const primaryLookupMiss = {
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
};
describe("addNoteToContact", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
describe("getOrCreateCompanyFromDomain", () => {
it("returns the existing company when it already exists in the database", async () => {
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom.mockReturnValue({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
});
const result = await getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
});
expect(result).toEqual(existingCompany);
expect(mockFrom).toHaveBeenCalledWith("companies");
});
it("throws when fetching the company fails", async () => {
mockFrom.mockReturnValue({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
await expect(
getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
}),
).rejects.toThrow(
"Could not fetch companies from database, name: acme.com, error: DB error",
);
});
it("returns null for a known mail provider domain without creating a company", async () => {
const result = await getOrCreateCompanyFromDomain({
domain: "gmail.com",
salesId: 42,
companyName: "Gmail",
website: "https://gmail.com",
});
expect(result).toBeNull();
// No database calls should be made for mail providers
expect(mockFrom).not.toHaveBeenCalled();
});
it("creates and returns a new company when it does not exist and domain is not a mail provider", async () => {
const newCompany = {
id: 2,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
// first call: fetch
select: () => ({
or: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// second call: insert
insert: () => ({
select: () => Promise.resolve({ data: [newCompany], error: null }),
}),
});
const result = await getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
});
expect(result).toEqual(newCompany);
expect(mockFrom).toHaveBeenCalledTimes(2);
expect(mockFrom).toHaveBeenNthCalledWith(2, "companies");
});
it("throws when creating the company fails", async () => {
mockFrom
.mockReturnValueOnce({
select: () => ({
or: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
insert: () => ({
select: () =>
Promise.resolve({
data: null,
error: { message: "Insert failed" },
}),
}),
});
await expect(
getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
}),
).rejects.toThrow(
"Could not create company in database, domain: acme.com, error: Insert failed",
);
});
});
describe("getOrCreateContactFromEmailInfo", () => {
const contactParams = {
email: "alice@acme.com",
firstName: "Alice",
lastName: "Smith",
salesId: 42,
domain: "acme.com",
companyName: "Acme",
website: "https://acme.com",
};
it("returns the existing contact when it already exists in the database", async () => {
const existingContact = {
id: 10,
first_name: "Alice",
last_name: "Smith",
};
mockFrom.mockReturnValue({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingContact, error: null }),
}),
}),
});
const result = await getOrCreateContactFromEmailInfo(contactParams);
expect(result).toEqual(existingContact);
expect(mockFrom).toHaveBeenCalledWith("contacts");
});
it("throws when fetching the contact fails", async () => {
mockFrom.mockReturnValue({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
await expect(
getOrCreateContactFromEmailInfo(contactParams),
).rejects.toThrow(
"Could not fetch contact from database, email: alice@acme.com, error: DB error",
);
});
it("creates and returns a new contact with the associated company", async () => {
const newContact = {
id: 11,
first_name: "Alice",
last_name: "Smith",
company_id: 1,
};
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
// 1st call: fetch contact → not found
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 2nd call: fetch company → found
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 3rd call: insert contact
insert: () => ({
select: () => Promise.resolve({ data: [newContact], error: null }),
}),
});
const result = await getOrCreateContactFromEmailInfo(contactParams);
expect(result).toEqual(newContact);
expect(mockFrom).toHaveBeenCalledTimes(3);
});
it("creates a contact with null company_id when domain is a mail provider", async () => {
const newContact = {
id: 12,
first_name: "Alice",
last_name: "Smith",
company_id: null,
};
mockFrom
.mockReturnValueOnce({
// 1st call: fetch contact → not found
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 2nd call: insert contact (with null company_id)
insert: () => ({
select: () => Promise.resolve({ data: [newContact], error: null }),
}),
});
const result = await getOrCreateContactFromEmailInfo({
...contactParams,
email: "alice@gmail.com",
domain: "gmail.com",
companyName: "Gmail",
website: "https://gmail.com",
});
expect(result).toEqual(newContact);
// Only 2 froms: contacts fetch, contacts insert (no company fetch/insert for mail provider)
expect(mockFrom).toHaveBeenCalledTimes(2);
});
it("throws when creating the contact fails", async () => {
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
})
.mockReturnValueOnce({
insert: () => ({
select: () =>
Promise.resolve({
data: null,
error: { message: "Insert failed" },
}),
}),
});
await expect(
getOrCreateContactFromEmailInfo(contactParams),
).rejects.toThrow(
"Could not create contact in database, email: alice@acme.com, error: Insert failed",
);
});
});
describe("findActiveSaleByEmail", () => {
it("returns the active sale whose primary email matches the sender", async () => {
const salesRecord = { id: 1, email: "sales@company.com" };
mockFrom.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () =>
Promise.resolve({ data: salesRecord, error: null }),
}),
}),
}),
});
const result = await findActiveSaleByEmail("sales@company.com");
expect(result.data).toEqual(salesRecord);
expect(mockFrom).toHaveBeenCalledTimes(1);
});
it("looks up the secondary emails when no primary email matches", async () => {
const salesRecord = {
id: 1,
email: "sales@company.com",
secondary_emails: ["perso@gmail.com"],
};
const contains = vi
.fn()
.mockReturnValue(secondaryLookupResult([salesRecord]));
mockFrom
.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
})
.mockReturnValueOnce({ select: () => ({ contains }) });
const result = await findActiveSaleByEmail("perso@gmail.com");
expect(result.data).toEqual(salesRecord);
expect(contains).toHaveBeenCalledWith(
"secondary_emails",
'["perso@gmail.com"]',
);
});
it("attributes to nobody when two sales share the same secondary email", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () =>
secondaryLookupResult([
{ id: 3, email: "a@company.com" },
{ id: 9, email: "b@company.com" },
]),
}),
});
const result = await findActiveSaleByEmail("shared@x.com");
expect(result.data).toBe(null);
expect(result.error).toBe(null);
consoleSpy.mockRestore();
});
it("names both sales in the logs when it refuses an ambiguous sender", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () =>
secondaryLookupResult([
{ id: 3, email: "a@company.com" },
{ id: 9, email: "b@company.com" },
]),
}),
});
await findActiveSaleByEmail("shared@x.com");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("shared@x.com"),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("3, 9"));
consoleSpy.mockRestore();
});
it("surfaces a failure of the secondary lookup instead of reporting no sale", async () => {
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () => secondaryLookupResult([], { message: "DB error" }),
}),
});
const result = await findActiveSaleByEmail("perso@gmail.com");
expect(result.data).toBe(null);
expect(result.error).toEqual({ message: "DB error" });
});
it("returns no sale rather than an error when nothing matches", async () => {
mockFrom
.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
})
.mockReturnValueOnce({
select: () => ({ contains: () => secondaryLookupResult([]) }),
});
const result = await findActiveSaleByEmail("nobody@x.com");
expect(result.data).toBe(null);
expect(result.error).toBe(null);
});
it("lowercases the sender, since the jsonb lookup is case sensitive unlike the citext column", async () => {
const salesRecord = {
id: 1,
email: "sales@company.com",
secondary_emails: ["perso@gmail.com"],
};
const eq = vi.fn().mockReturnValue({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
});
const contains = vi
.fn()
.mockReturnValue(secondaryLookupResult([salesRecord]));
mockFrom
.mockReturnValueOnce({ select: () => ({ eq }) })
.mockReturnValueOnce({ select: () => ({ contains }) });
const result = await findActiveSaleByEmail(" Perso@Gmail.COM ");
expect(eq).toHaveBeenCalledWith("email", "perso@gmail.com");
expect(contains).toHaveBeenCalledWith(
"secondary_emails",
'["perso@gmail.com"]',
);
expect(result.data).toEqual(salesRecord);
});
it("does not look up the secondary emails when the first query fails", async () => {
mockFrom.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
}),
});
const result = await findActiveSaleByEmail("sales@company.com");
expect(result.error).toEqual({ message: "DB error" });
expect(mockFrom).toHaveBeenCalledTimes(1);
});
});
describe("addNoteToContact", () => {
const sales = { id: 1 };
const baseParams = {
sales,
salesEmail: "sales@company.com",
email: "alice@acme.com",
domain: "acme.com",
firstName: "Alice",
lastName: "Smith",
noteContent: "A note",
attachments: [],
companyName: "Acme",
website: "https://acme.com",
};
const contactFound = (contact: unknown) => ({
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: contact, error: null }),
}),
}),
});
it("creates a note and returns undefined on success", async () => {
const existingContact = {
id: 10,
first_name: "Alice",
last_name: "Smith",
};
mockFrom
.mockReturnValueOnce(contactFound(existingContact))
.mockReturnValueOnce({
insert: () => Promise.resolve({ error: null }),
})
.mockReturnValueOnce({
update: () => ({
eq: () => Promise.resolve({ error: null }),
}),
});
const result = await addNoteToContact(baseParams);
expect(result).toBeUndefined();
expect(mockFrom).toHaveBeenCalledTimes(3);
expect(mockFrom).toHaveBeenNthCalledWith(2, "contact_notes");
expect(mockFrom).toHaveBeenNthCalledWith(3, "contacts");
});
it("files the note under the sale it was given, without looking one up", async () => {
const insertNote = vi.fn().mockResolvedValue({ error: null });
mockFrom
.mockReturnValueOnce(contactFound({ id: 10 }))
.mockReturnValueOnce({ insert: insertNote })
.mockReturnValueOnce({
update: () => ({
eq: () => Promise.resolve({ error: null }),
}),
});
const result = await addNoteToContact({
...baseParams,
sales: { id: 7 },
});
expect(result).toBeUndefined();
expect(insertNote).toHaveBeenCalledWith(
expect.objectContaining({ sales_id: 7, contact_id: 10 }),
);
expect(mockFrom).not.toHaveBeenCalledWith("sales");
});
it("returns 500 when inserting the note into contact_notes fails", async () => {
mockFrom
.mockReturnValueOnce(contactFound({ id: 10 }))
.mockReturnValueOnce({
insert: () =>
Promise.resolve({ error: { message: "Insert failed" } }),
});
const response = await addNoteToContact(baseParams);
expect(response).toBeInstanceOf(Response);
expect(response!.status).toBe(500);
expect(await response!.text()).toBe(
"Could not add note to contact alice@acme.com, sales sales@company.com",
);
expect(mockFrom).toHaveBeenCalledTimes(2);
});
it("returns 500 when getOrCreateContactFromEmailInfo throws", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
const response = await addNoteToContact(baseParams);
expect(response).toBeInstanceOf(Response);
expect(response!.status).toBe(500);
expect(await response!.text()).toBe(
"Could not get or create contact from database, email: alice@acme.com, sales: sales@company.com",
);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,229 @@
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import type { Attachment } from "./extractAndUploadAttachments.ts";
import { MAIL_PROVIDERS } from "./mailProvider.const.ts";
export const getOrCreateCompanyFromDomain = async ({
domain,
salesId,
companyName,
website,
}: {
domain: string;
salesId: number;
companyName: string;
website: string;
}) => {
if (MAIL_PROVIDERS.includes(domain)) {
// We don't want to create companies for generic mail providers, as they are not really companies and it would pollute the database with useless entries.
return null;
}
// Check if the company already exists
const { data: existingCompany, error: fetchCompanyError } =
await supabaseAdmin
.from("companies")
.select("*")
.or(`website.eq.${website},name.eq.${companyName},name.eq.${domain}`)
.maybeSingle();
if (fetchCompanyError) {
throw new Error(
`Could not fetch companies from database, name: ${domain}, error: ${fetchCompanyError.message}`,
);
}
if (existingCompany) {
return existingCompany;
}
const { data: newCompanies, error: createCompanyError } = await supabaseAdmin
.from("companies")
.insert({ name: companyName, sales_id: salesId, website })
.select();
if (createCompanyError) {
throw new Error(
`Could not create company in database, domain: ${domain}, error: ${createCompanyError.message}`,
);
}
return newCompanies[0];
};
export const getOrCreateContactFromEmailInfo = async ({
email,
firstName,
lastName,
salesId,
domain,
companyName,
website,
}: {
email: string;
firstName: string;
lastName: string;
salesId: number;
domain: string;
companyName: string;
website: string;
}) => {
// Check if the contact already exists
const { data: existingContact, error: fetchContactError } =
await supabaseAdmin
.from("contacts")
.select("*")
.contains("email_jsonb", JSON.stringify([{ email }]))
.maybeSingle();
if (fetchContactError) {
throw new Error(
`Could not fetch contact from database, email: ${email}, error: ${fetchContactError.message}`,
);
}
if (existingContact) {
return existingContact;
}
const company = await getOrCreateCompanyFromDomain({
domain,
salesId,
companyName,
website,
});
// Create the contact
const { data: newContacts, error: createContactError } = await supabaseAdmin
.from("contacts")
.insert({
first_name: firstName,
last_name: lastName,
email_jsonb: [{ email, type: "Work" }],
company_id: company ? company.id : null,
sales_id: salesId,
first_seen: new Date(),
last_seen: new Date(),
tags: [],
})
.select();
if (createContactError || !newContacts[0]) {
throw new Error(
`Could not create contact in database, email: ${email}, error: ${createContactError?.message}`,
);
}
return newContacts[0];
};
export const findActiveSaleByEmail = async (salesEmail: string) => {
const normalizedEmail = salesEmail.trim().toLowerCase();
const byPrimaryEmail = await supabaseAdmin
.from("sales")
.select("*")
.eq("email", normalizedEmail)
.neq("disabled", true)
.maybeSingle();
if (byPrimaryEmail.error || byPrimaryEmail.data) {
return byPrimaryEmail;
}
const bySecondaryEmail = await supabaseAdmin
.from("sales")
.select("*")
.contains("secondary_emails", JSON.stringify([normalizedEmail]))
.neq("disabled", true)
.order("id", { ascending: true })
.limit(2);
if (bySecondaryEmail.error) {
return { data: null, error: bySecondaryEmail.error };
}
const matches = bySecondaryEmail.data ?? [];
if (matches.length > 1) {
console.error(
`Ambiguous sender ${normalizedEmail}: registered as a secondary email by sales ${matches
.map((sale: { id: number }) => sale.id)
.join(", ")}. Refusing to attribute the note.`,
);
return { data: null, error: null };
}
return { data: matches[0] ?? null, error: null };
};
export const addNoteToContact = async ({
sales,
salesEmail,
email,
domain,
firstName,
lastName,
noteContent,
attachments,
companyName,
website,
}: {
sales: { id: number };
salesEmail: string;
email: string;
domain: string;
firstName: string;
lastName: string;
noteContent: string;
attachments: Attachment[];
companyName: string;
website: string;
}) => {
const { contact, error } = await getOrCreateContactFromEmailInfo({
email,
firstName,
lastName,
salesId: sales.id,
domain,
companyName,
website,
})
.then((contact) => ({
contact,
}))
.catch((error) => {
return {
error,
};
});
if (error) {
console.error(
"Error in getOrCreateContactFromEmailInfo for email:",
email,
"sales:",
salesEmail,
"error:",
error,
);
return new Response(
`Could not get or create contact from database, email: ${email}, sales: ${salesEmail}`,
{ status: 500 },
);
}
// Add note to contact
const { error: createNoteError } = await supabaseAdmin
.from("contact_notes")
.insert({
contact_id: contact.id,
text: noteContent,
sales_id: sales.id,
attachments,
});
if (createNoteError) {
return new Response(
`Could not add note to contact ${email}, sales ${salesEmail}`,
{ status: 500 },
);
}
await supabaseAdmin
.from("contacts")
.update({ last_seen: new Date() })
.eq("id", contact.id);
};

View File

@@ -0,0 +1,6 @@
{
"imports": {
"base64-arraybuffer": "npm:base64-arraybuffer",
"tldts": "npm:tldts"
}
}

View File

@@ -0,0 +1,109 @@
import { decode } from "base64-arraybuffer";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
export type Attachment = {
title: string;
type: string;
path: string;
src: string;
};
/**
* Extracts the attachments from the email and upload them to Supabase Storage.
*
* Example:
* "Attachments": [
* {
* "Name": "test.txt",
* "Content": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
* "ContentType": "text/plain",
* "ContentLength": 45
* }
* ]
*
* Return Value:
* [{
* title: "test.txt",
* type: "text/plain",
* "path": "0.8262106278726917.txt",
* "src": "http://127.0.0.1:54321/storage/v1/object/public/attachments/0.8262106278726917.txt",
* }]
*
*/
export const extractAndUploadAttachments = async (
Attachments: {
Name: string;
Content: string;
ContentType: string;
ContentLength: number;
}[],
): Promise<Attachment[]> => {
return (
await Promise.all(
(Attachments || []).map(async (attachment) => {
const { Name, Content, ContentType } = attachment;
if (!Name || !Content || !ContentType) {
console.warn("Attachment is missing required fields, skipping", {
attachment,
});
return null;
}
const decodedContent = decode(Content);
if (!decodedContent) {
console.error("Failed to decode attachment content, skipping", {
attachment,
});
return null;
}
const fileParts = Name.split(".");
const fileExt = fileParts.length > 1 ? `.${Name.split(".").pop()}` : "";
const fileName = `${Math.random()}${fileExt}`;
const { error: uploadError } = await supabaseAdmin.storage
.from("attachments")
.upload(fileName, decodedContent);
if (uploadError) {
console.error("uploadError", uploadError);
throw new Error("Failed to upload attachment");
}
const { data } = supabaseAdmin.storage
.from("attachments")
.getPublicUrl(fileName);
return {
title: Name,
type: ContentType,
path: fileName,
src: fixPublicUrl(data.publicUrl),
};
}),
)
).filter(Boolean) as Attachment[];
};
/*
* Workaround fix for public URL not working on local environment
* See https://github.com/orgs/supabase/discussions/37271
*
* Replaces http://kong:8000/storage/v1/object/public/attachments/0.08968261048718773.txt with http://127.0.0.1:54321/storage/v1/object/public/attachments/0.08968261048718773.txt, using the SB_JWT_ISSUER env var (value http://127.0.0.1:54321/auth/v1) to get the external/public URL of the Supabase instance.
*/
const fixPublicUrl = (url: string) => {
const jwtIssuer = Deno.env.get("SB_JWT_ISSUER") ?? "";
const localUrl = jwtIssuer.replace("/auth/v1", "");
return url.replace("http://kong:8000", localUrl);
};
export const removeUploadedAttachments = async (attachments: Attachment[]) => {
if (!attachments.length) {
return;
}
const { error } = await supabaseAdmin.storage
.from("attachments")
.remove(attachments.map((attachment) => attachment.path));
if (error) {
console.error("Could not remove the uploaded attachments", error);
}
};

View File

@@ -0,0 +1,216 @@
import { describe, it, expect } from "vitest";
import {
extractMailContactData,
extractCompanyName,
} from "./extractMailContactData";
describe("extractMailContactData", () => {
it("extracts first name, last name, email, domain, company name, and website when Name is provided", () => {
expect(
extractMailContactData([
{
Email: "firstname.lastname@marmelab.com",
Name: "Firstname Lastname",
},
]),
).toEqual([
{
firstName: "Firstname",
lastName: "Lastname",
email: "firstname.lastname@marmelab.com",
domain: "marmelab.com",
companyName: "Marmelab",
website: "https://marmelab.com",
},
]);
});
it("derives name from email address when Name is empty", () => {
expect(
extractMailContactData([{ Email: "john.doe@example.com", Name: "" }]),
).toEqual([
{
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
domain: "example.com",
companyName: "Example",
website: "https://example.com",
},
]);
});
it("sets empty firstName and capitalizes lastName when name has no space", () => {
expect(
extractMailContactData([{ Email: "alice@example.com", Name: "Alice" }]),
).toEqual([
{
firstName: "",
lastName: "Alice",
companyName: "Example",
website: "https://example.com",
email: "alice@example.com",
domain: "example.com",
},
]);
});
it("handles multi-part last name", () => {
expect(
extractMailContactData([
{
Email: "jean.de.la.fontaine@example.com",
Name: "Jean De La Fontaine",
},
]),
).toEqual([
{
firstName: "Jean",
lastName: "De La Fontaine",
companyName: "Example",
website: "https://example.com",
email: "jean.de.la.fontaine@example.com",
domain: "example.com",
},
]);
});
it("capitalizes names derived from email address", () => {
expect(
extractMailContactData([{ Email: "jane.smith@company.org", Name: "" }]),
).toEqual([
{
companyName: "Company",
website: "https://company.org",
firstName: "Jane",
lastName: "Smith",
email: "jane.smith@company.org",
domain: "company.org",
},
]);
});
it("capitalizes names derived from Name field", () => {
expect(
extractMailContactData([
{
Email: "jane.smith@company.org",
Name: "jane smith",
},
]),
).toEqual([
{
companyName: "Company",
website: "https://company.org",
firstName: "Jane",
lastName: "Smith",
email: "jane.smith@company.org",
domain: "company.org",
},
]);
});
it("processes multiple contacts", () => {
const result = extractMailContactData([
{ Email: "alice@foo.com", Name: "Alice Wonder" },
{ Email: "bob@bar.com", Name: "Bob Builder" },
]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
firstName: "Alice",
lastName: "Wonder",
domain: "foo.com",
companyName: "Foo",
website: "https://foo.com",
});
expect(result[1]).toMatchObject({
firstName: "Bob",
lastName: "Builder",
domain: "bar.com",
companyName: "Bar",
website: "https://bar.com",
});
});
it("returns an empty array for empty input", () => {
expect(extractMailContactData([])).toEqual([]);
});
it("should lowercase email addresses", () => {
expect(
extractMailContactData([
{
Email: "Firstname.Lastname@marmelab.com",
Name: "Firstname Lastname",
},
]),
).toEqual([
{
firstName: "Firstname",
lastName: "Lastname",
email: "firstname.lastname@marmelab.com",
domain: "marmelab.com",
companyName: "Marmelab",
website: "https://marmelab.com",
},
]);
});
describe("extractCompanyName", () => {
it("extracts company name from lowercase domain", () => {
expect(extractCompanyName("marmelab.com")).toBe("Marmelab");
});
it("extracts company name from uppercase domain", () => {
expect(extractCompanyName("Marmelab.com")).toBe("Marmelab");
});
it("extracts company name from common free email provider", () => {
expect(extractCompanyName("gmail.com")).toBe("Gmail");
});
it("extracts company name from multi-part TLD (.co.uk)", () => {
expect(extractCompanyName("example.co.uk")).toBe("Example");
});
it("extracts company name with hyphen and converts to title case", () => {
expect(extractCompanyName("example-company.co.uk")).toBe(
"Example Company",
);
});
it("extracts company name with underscore and converts to title case", () => {
expect(extractCompanyName("Example_Company.com")).toBe("Example Company");
});
it("extracts company name with multiple hyphens", () => {
expect(extractCompanyName("my-super-cool-company.com")).toBe(
"My Super Cool Company",
);
});
it("extracts company name with multiple underscores", () => {
expect(extractCompanyName("my_super_cool_company.com")).toBe(
"My Super Cool Company",
);
});
it("extracts company name with mixed separators", () => {
expect(extractCompanyName("my-super_cool-company.com")).toBe(
"My Super Cool Company",
);
});
it("handles invalid or unknown TLD domains gracefully", () => {
expect(extractCompanyName("invalid.domain")).toBe("Invalid");
});
it("returns empty string for empty domain", () => {
expect(extractCompanyName("")).toBe("");
});
it("handles domain with no TLD separator", () => {
expect(extractCompanyName("nodots")).toBe("Nodots");
});
});
});

View File

@@ -0,0 +1,75 @@
import { parse } from "tldts";
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export function extractCompanyName(domain: string) {
if (!domain) return "";
let name = domain.split(".").at(-2) || domain;
const parseResult = parse(domain);
if (parseResult.domainWithoutSuffix) {
name = parseResult.domainWithoutSuffix;
}
const humanizedName = name
.replace(/[-_]+/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return humanizedName;
}
/**
* Extracts the first name, last name, email, and domain from a mail contact.
*
* Example:
* "ToFull": [
* {
* "Email": "firstname.lastname@marmelab.com",
* "Name": "Firstname Lastname"
* }
* ]
*
* Return Value:
* {
* firstName: "Firstname",
* lastName: "Lastname",
* email: "firstname.lastname@marmelab.com",
* domain: "marmelab.com"
* }
*
*/
export const extractMailContactData = (
ToFull: {
Email: string;
Name: string;
}[],
) => {
return ToFull.map((contact) => ({
...contact,
Email: (contact.Email || "").toLowerCase(),
})).map((contact) => {
const domain = (contact.Email.split("@").at(-1) || "").toLowerCase();
const fullName =
contact.Name ||
contact.Email.split("@").slice(0, -1).join(" ").split(".").join(" ");
let firstName = "";
let lastName = fullName;
if (fullName && fullName.includes(" ")) {
const parts = fullName.split(" ");
firstName = parts[0];
lastName = parts.slice(1).join(" ");
}
const companyName = extractCompanyName(domain);
const website = `https://${domain}`;
return {
firstName: capitalize(firstName),
lastName: capitalize(lastName),
email: contact.Email,
domain,
companyName,
website,
};
});
};

View File

@@ -0,0 +1,321 @@
import { describe, it, expect, vi } from "vitest";
import {
stripForwardingHeaderBlock,
stripSubjectForwardingPrefix,
stripMailSignature,
getForwardedMailContent,
} from "./forwardedParser";
describe("forwardedParser", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("stripForwardingHeaderBlock", () => {
describe("returns original text when no forwarding separator is found", () => {
it("returns the original text for plain content", () => {
const text = "Hello, this is a regular email body.";
expect(stripForwardingHeaderBlock(text)).toBe(text);
});
it("trims whitespace when no separator is found", () => {
const text = " Hello world ";
expect(stripForwardingHeaderBlock(text)).toBe("Hello world");
});
it("returns original text when separator is not on the first line", () => {
const text =
"Some intro line\n---------- Forwarded message ----------\nActual content";
expect(stripForwardingHeaderBlock(text)).toBe(text.trim());
});
});
describe("Gmail forwarded separator", () => {
it("strips the Gmail forwarded header block", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
"Date: Mon, 24 Feb 2026 10:00:00 +0000",
"Subject: Meeting notes",
"To: Bob <bob@example.com>",
"",
"Hi Bob,",
"",
"Here are the meeting notes.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Hi Bob,\n\nHere are the meeting notes.",
);
});
});
describe("Apple Mail forwarded separator", () => {
it("strips the Apple Mail forwarded header block", () => {
const text = [
"Begin forwarded message:",
"From: Alice <alice@example.com>",
"Subject: Hello",
"Date: 24 Feb 2026",
"To: Bob <bob@example.com>",
"",
"This is the original message.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"This is the original message.",
);
});
});
describe("Outlook / Exchange forwarded separator", () => {
it("strips the Outlook forwarded header block", () => {
const text = [
"-----Original Message-----",
"From: Alice <alice@example.com>",
"Sent: Monday, February 24, 2026 10:00 AM",
"To: Bob <bob@example.com>",
"Subject: Project update",
"",
"Please find the update below.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Please find the update below.",
);
});
});
describe("French forwarded separators", () => {
it("strips the 'Message transféré' header block", () => {
const text = [
"---------- Message transféré ----------",
"De : Alice <alice@example.com>",
"Date : 24 févr. 2026",
"Objet : Réunion",
"",
"Bonjour,",
"",
"Voici le message.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Bonjour,\n\nVoici le message.",
);
});
it("strips the 'Transféré' header block", () => {
const text = [
"----- Transféré -----",
"De : Alice <alice@example.com>",
"",
"Le message transféré.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Le message transféré.");
});
it("strips the ASCII-variant 'Message transfere' header block", () => {
const text = [
"---------- Message transfere ----------",
"De : Alice <alice@example.com>",
"",
"Bonjour.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Bonjour.");
});
});
describe("German forwarded separator", () => {
it("strips the 'Weitergeleitete Nachricht' header block", () => {
const text = [
"---------- Weitergeleitete Nachricht ----------",
"Von: Alice <alice@example.com>",
"Datum: 24. Feb. 2026",
"Betreff: Projekt",
"",
"Hallo,",
"",
"Hier ist die Nachricht.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Hallo,\n\nHier ist die Nachricht.",
);
});
});
describe("edge cases", () => {
it("strip everything when there is only one forwarded block and nothing after", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("");
});
it("preserves multi-paragraph body content", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
"",
"First paragraph.",
"",
"Second paragraph.",
"",
"Third paragraph.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
);
});
it("trims leading and trailing whitespace from the result", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice",
"",
" ",
" Body content ",
" ",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Body content");
});
});
});
describe("stripSubjectForwardingPrefix", () => {
it("strips 'Fwd:' prefix", () => {
expect(stripSubjectForwardingPrefix("Fwd: Hello")).toBe("Hello");
});
it("strips 'FW:' prefix", () => {
expect(stripSubjectForwardingPrefix("FW: Hello")).toBe("Hello");
});
it("strips 'FWD:' prefix", () => {
expect(stripSubjectForwardingPrefix("FWD: Hello")).toBe("Hello");
});
it("strips 'Tr:' prefix (French)", () => {
expect(stripSubjectForwardingPrefix("Tr: Bonjour")).toBe("Bonjour");
});
it("strips 'SV:' prefix (Scandinavian)", () => {
expect(stripSubjectForwardingPrefix("SV: Hei")).toBe("Hei");
});
it("strips 'VS:' prefix", () => {
expect(stripSubjectForwardingPrefix("VS: Hallo")).toBe("Hallo");
});
it("strips 'WG:' prefix (German)", () => {
expect(stripSubjectForwardingPrefix("WG: Hallo")).toBe("Hallo");
});
it("is case-insensitive", () => {
expect(stripSubjectForwardingPrefix("fwd: lowercase")).toBe("lowercase");
expect(stripSubjectForwardingPrefix("FWD - Mixed")).toBe("Mixed");
});
it("returns the subject unchanged when no prefix is present", () => {
expect(stripSubjectForwardingPrefix("Meeting notes")).toBe(
"Meeting notes",
);
});
it("handles dash separator instead of colon", () => {
expect(stripSubjectForwardingPrefix("Fwd - My subject")).toBe(
"My subject",
);
});
it("should return original subject if stripping results in empty string", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const subject = "Fwd: ";
expect(stripSubjectForwardingPrefix(subject)).toBe(subject);
});
});
describe("stripMailSignature", () => {
it("strips the mail signature when separator is present", () => {
const text = "Hello,\nThis is the email body.\n-- \nSignature here";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
it("returns original text when no signature separator is found", () => {
const text = "Hello,\nThis is the email body.\nBest regards.";
expect(stripMailSignature(text)).toBe(text);
});
it("handles multiple occurrences of the signature separator", () => {
const text =
"Hello,\nThis is the email body.\n-- \nFirst signature\n-- \nSecond signature";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
it("trims whitespace from the result", () => {
const text = "Hello,\nThis is the email body.\n-- \nSignature here ";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
});
describe("getForwardedMailContent", () => {
it("strips both the forwarding header block and the mail signature", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"Hi Bob,",
"This is the original message.",
"-- \nAlice's signature",
].join("\n");
expect(getForwardedMailContent(text)).toBe(
"Hi Bob,\nThis is the original message.",
);
});
it("returns original text when stripping results in empty content", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"-- \nAlice's signature",
].join("\n");
// Both stripping operations would result in empty content, so original text should be returned.
expect(getForwardedMailContent(text)).toBe(text.trim());
});
it("handles text with no forwarding header and no signature", () => {
const text = "Hello,\nThis is a simple email body.";
expect(getForwardedMailContent(text)).toBe(text);
});
it("handles text with only a forwarding header", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"This is the original message.",
].join("\n");
expect(getForwardedMailContent(text)).toBe(
"This is the original message.",
);
});
it("handles text with only a mail signature", () => {
const text = "Hello,\nThis is the email body.\n-- \nBest regards.";
expect(getForwardedMailContent(text)).toBe(
"Hello,\nThis is the email body.",
);
});
});
});

View File

@@ -0,0 +1,83 @@
/** Patterns that mark the beginning of a forwarded block in the email body. */
const FORWARD_SEPARATOR_PATTERNS = [
// Gmail
/^-{5,}\s*Forwarded message\s*-{5,}/im,
// Apple Mail
/^Begin forwarded message:/im,
// Outlook / Exchange
/^-{5,}\s*Original Message\s*-{5,}/im,
// French clients (Transféré / Message transféré)
/^-{5,}\s*Message transf[eé]r[eé]\s*-{5,}/im,
/^-{5,}\s*Transf[eé]r[eé]\s*-{5,}/im,
// German (Weitergeleitet)
/^-{5,}\s*Weitergeleitete Nachricht\s*-{5,}/im,
];
export const stripForwardingHeaderBlock = (text: string): string => {
const lines = text.split("\n");
let bodyStartIndex = 0;
// First check that the first line matches a known forwarding separator pattern
const hasForwardedSeparator = FORWARD_SEPARATOR_PATTERNS.some((pattern) => {
const match = lines[0].match(pattern);
return !!match;
});
if (!hasForwardedSeparator) {
// No known forwarding pattern detected, return the original text
return text.trim();
}
// Walk through the header-like lines at the top of the block
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Blank line signals end of the forwarded header block
if (line.trim() === "") {
bodyStartIndex = i + 1;
break;
}
i++;
}
// No blank line found after the header → no body to extract
if (bodyStartIndex === 0) {
return "";
}
return lines.slice(bodyStartIndex).join("\n").trim();
};
export const stripSubjectForwardingPrefix = (subject: string): string => {
const result = subject
.replace(/^(Fwd|FW|FWD|Tr|SV|VS|WG|WG:)\s*[:-]\s*/i, "")
.trim();
if (result.length === 0) {
console.warn(
`Stripping forwarding prefix from subject "${subject}" resulted in empty string, returning original subject`,
);
return subject;
}
return result;
};
export const stripMailSignature = (text: string): string => {
const signatureSeparatorIndex = text.indexOf("\n-- \n");
if (signatureSeparatorIndex !== -1) {
return text.substring(0, signatureSeparatorIndex).trim();
}
return text.trim();
};
export const getForwardedMailContent = (body: string): string => {
const strippedBody = stripForwardingHeaderBlock(stripMailSignature(body));
if (strippedBody.length === 0) {
console.warn(
"Stripping mail signature and forwarded header block resulted in empty note content, returning original body",
);
return body.trim();
}
return strippedBody;
};

View File

@@ -0,0 +1,4 @@
export const getExpectedAuthorization = (
webhookUser: string,
webhookPassword: string,
) => `Basic ${btoa(`${webhookUser}:${webhookPassword}`)}`;

View File

@@ -0,0 +1,6 @@
export const getNoteContent = (
subject: string,
strippedText: string,
) => `${subject}
${strippedText}`;

View 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\">&lt;<a href=\"mailto:original.recipient@company.com\">original.recipient@company.com</a>&gt;</span><br>Date: Fri, 1 Aug 2014 16:45:32 -04:00<br>Subject: Test for forwarding mail<br>To: Postmarkapp Support &lt;<a href=\"mailto:support@postmarkapp.com\">support@postmarkapp.com</a>&gt;<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": []
}'
*/

View File

@@ -0,0 +1,104 @@
export const MAIL_PROVIDERS = [
"gmail.com",
"yahoo.com",
"hotmail.com",
"aol.com",
"hotmail.co.uk",
"hotmail.fr",
"msn.com",
"yahoo.fr",
"wanadoo.fr",
"orange.fr",
"comcast.net",
"yahoo.co.uk",
"yahoo.com.br",
"yahoo.co.in",
"live.com",
"rediffmail.com",
"free.fr",
"gmx.de",
"web.de",
"yandex.ru",
"ymail.com",
"libero.it",
"outlook.com",
"uol.com.br",
"bol.com.br",
"mail.ru",
"cox.net",
"hotmail.it",
"sbcglobal.net",
"sfr.fr",
"live.fr",
"verizon.net",
"live.co.uk",
"googlemail.com",
"yahoo.es",
"ig.com.br",
"live.nl",
"bigpond.com",
"terra.com.br",
"yahoo.it",
"neuf.fr",
"yahoo.de",
"alice.it",
"rocketmail.com",
"att.net",
"laposte.net",
"facebook.com",
"bellsouth.net",
"yahoo.in",
"hotmail.es",
"charter.net",
"yahoo.ca",
"yahoo.com.au",
"rambler.ru",
"hotmail.de",
"tiscali.it",
"shaw.ca",
"yahoo.co.jp",
"sky.com",
"earthlink.net",
"optonline.net",
"freenet.de",
"t-online.de",
"aliceadsl.fr",
"virgilio.it",
"home.nl",
"qq.com",
"telenet.be",
"me.com",
"yahoo.com.ar",
"tiscali.co.uk",
"yahoo.com.mx",
"voila.fr",
"gmx.net",
"mail.com",
"planet.nl",
"tin.it",
"live.it",
"ntlworld.com",
"arcor.de",
"yahoo.co.id",
"frontiernet.net",
"hetnet.nl",
"live.com.au",
"yahoo.com.sg",
"zonnet.nl",
"club-internet.fr",
"juno.com",
"optusnet.com.au",
"blueyonder.co.uk",
"bluewin.ch",
"skynet.be",
"sympatico.ca",
"windstream.net",
"mac.com",
"centurytel.net",
"chello.nl",
"live.ca",
"aim.com",
"bigpond.net.au",
"online.de",
"apple.com",
];