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:
647
supabase/functions/postmark/addNoteToContact.test.ts
Normal file
647
supabase/functions/postmark/addNoteToContact.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
229
supabase/functions/postmark/addNoteToContact.ts
Normal file
229
supabase/functions/postmark/addNoteToContact.ts
Normal 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);
|
||||
};
|
||||
6
supabase/functions/postmark/deno.json
Normal file
6
supabase/functions/postmark/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"base64-arraybuffer": "npm:base64-arraybuffer",
|
||||
"tldts": "npm:tldts"
|
||||
}
|
||||
}
|
||||
109
supabase/functions/postmark/extractAndUploadAttachments.ts
Normal file
109
supabase/functions/postmark/extractAndUploadAttachments.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
216
supabase/functions/postmark/extractMailContactData.test.ts
Normal file
216
supabase/functions/postmark/extractMailContactData.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
75
supabase/functions/postmark/extractMailContactData.ts
Normal file
75
supabase/functions/postmark/extractMailContactData.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
321
supabase/functions/postmark/forwardedParser.test.ts
Normal file
321
supabase/functions/postmark/forwardedParser.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
supabase/functions/postmark/forwardedParser.ts
Normal file
83
supabase/functions/postmark/forwardedParser.ts
Normal 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;
|
||||
};
|
||||
4
supabase/functions/postmark/getExpectedAuthorization.ts
Normal file
4
supabase/functions/postmark/getExpectedAuthorization.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const getExpectedAuthorization = (
|
||||
webhookUser: string,
|
||||
webhookPassword: string,
|
||||
) => `Basic ${btoa(`${webhookUser}:${webhookPassword}`)}`;
|
||||
6
supabase/functions/postmark/getNoteContent.ts
Normal file
6
supabase/functions/postmark/getNoteContent.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const getNoteContent = (
|
||||
subject: string,
|
||||
strippedText: string,
|
||||
) => `${subject}
|
||||
|
||||
${strippedText}`;
|
||||
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": []
|
||||
}'
|
||||
|
||||
*/
|
||||
104
supabase/functions/postmark/mailProvider.const.ts
Normal file
104
supabase/functions/postmark/mailProvider.const.ts
Normal 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",
|
||||
];
|
||||
Reference in New Issue
Block a user