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

15
supabase/functions/.env Normal file
View File

@@ -0,0 +1,15 @@
POSTMARK_WEBHOOK_USER=testuser
POSTMARK_WEBHOOK_PASSWORD=testpwd
VITE_INBOUND_EMAIL=2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com
# IPs can be found at https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks
# 172.18.0.1, 172.19.0.1 and 172.20.0.1 are for local dev only
# 192.168.65.1 is for Docker Desktop on Mac
POSTMARK_WEBHOOK_AUTHORIZED_IPS=3.134.147.250,50.31.156.6,50.31.156.77,18.217.206.57,172.18.0.1,172.19.0.1,192.168.65.1,172.20.0.1
SB_JWT_ISSUER=http://127.0.0.1:54321/auth/v1
SB_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
# Public URL of the CRM app — used by the task-list MCP App to link
# contact names back to the CRM. Leave empty to render names as plain text.
CRM_BASE_URL=http://localhost:5173

View File

@@ -0,0 +1,80 @@
// Based on https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/_shared/jwt/default.ts
import * as jose from "jsr:@panva/jose@6";
import { createClient, type User } from "jsr:@supabase/supabase-js@2";
import { createErrorResponse } from "./utils.ts";
const SUPABASE_JWT_ISSUER =
Deno.env.get("SB_JWT_ISSUER") ?? Deno.env.get("SUPABASE_URL") + "/auth/v1";
const SUPABASE_JWT_KEYS = jose.createRemoteJWKSet(
new URL(Deno.env.get("SUPABASE_URL")! + "/auth/v1/.well-known/jwks.json"),
);
function getAuthToken(req: Request) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const [bearer, token] = authHeader.split(" ");
if (bearer !== "Bearer") {
throw new Error(`Auth header is not 'Bearer {token}'`);
}
return token;
}
function verifySupabaseJWT(jwt: string) {
return jose.jwtVerify(jwt, SUPABASE_JWT_KEYS, {
issuer: SUPABASE_JWT_ISSUER,
});
}
/**
* Validates the Authorization header to ensure that a user is authenticated.
*/
export const AuthMiddleware = async (
req: Request,
next: (req: Request) => Promise<Response>,
) => {
if (req.method === "OPTIONS") return await next(req);
try {
const token = getAuthToken(req);
const isValidJWT = await verifySupabaseJWT(token);
if (isValidJWT) return await next(req);
return createErrorResponse(401, "Invalid authentication");
} catch (e) {
return createErrorResponse(401, e?.toString() || "Unauthorized");
}
};
/**
* Get the authenticated user using the authorization header.
* User will be undefined for OPTIONS requests.
*/
export const UserMiddleware = async (
req: Request,
next: (req: Request, user?: User) => Promise<Response>,
) => {
if (req.method === "OPTIONS") return await next(req);
try {
const authHeader = req.headers.get("Authorization")!;
const localClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SB_PUBLISHABLE_KEY") ?? "",
{ global: { headers: { Authorization: authHeader } } },
);
const { data, error: authError } = await localClient.auth.getUser();
if (!data?.user || authError) {
return createErrorResponse(401, "Unauthorized");
}
return next(req, data.user);
} catch (err) {
return createErrorResponse(401, err?.toString() || "Unauthorized");
}
};

View File

@@ -0,0 +1,20 @@
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, PATCH, DELETE",
};
/**
* Handle OPTIONS requests for CORS preflight.
*/
export function OptionsMiddleware(
req: Request,
next: (req: Request) => Promise<Response>,
) {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
return next(req);
}

View File

@@ -0,0 +1,187 @@
import type {
DatabaseConnection,
Driver,
QueryResult,
} from "https://esm.sh/kysely@0.27.2";
import {
CompiledQuery,
Kysely,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
type Generated,
} from "https://esm.sh/kysely@0.27.2";
export { CompiledQuery };
import type { PoolClient } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
// Database schema types
export interface ContactsTable {
id: Generated<number>;
first_name: string | null;
last_name: string | null;
gender: string | null;
title: string | null;
email_jsonb: unknown | null; // JSONB array
phone_jsonb: unknown | null; // JSONB array
background: string | null;
avatar: unknown | null; // JSONB
first_seen: Date | null;
last_seen: Date | null;
has_newsletter: boolean | null;
status: string | null;
tags: number[] | null;
company_id: number | null;
sales_id: number | null;
linkedin_url: string | null;
}
interface TasksTable {
id: Generated<number>;
contact_id: number;
type: string | null;
text: string | null;
due_date: Date;
done_date: Date | null;
sales_id: number | null;
}
interface ContactNotesTable {
id: Generated<number>;
contact_id: number;
text: string | null;
date: Date | null;
sales_id: number | null;
status: string | null;
attachments: unknown[] | null; // JSONB array
}
interface DealsTable {
id: Generated<number>;
name: string;
company_id: number | null;
contact_ids: number[];
category: string | null;
stage: string;
description: string | null;
amount: number | null;
created_at: Date;
updated_at: Date;
archived_at: Date | null;
expected_closing_date: Date | null;
sales_id: number | null;
index: number | null;
}
interface Database {
contacts: ContactsTable;
tasks: TasksTable;
contact_notes: ContactNotesTable;
deals: DealsTable;
}
// Deno Postgres Driver for Kysely
class DenoPostgresDriver implements Driver {
private pool: Pool;
private connections = new WeakMap<PoolClient, DatabaseConnection>();
constructor(pool: Pool) {
this.pool = pool;
}
async init(): Promise<void> {
// Connection pool is already initialized
}
async acquireConnection(): Promise<DatabaseConnection> {
const client = await this.pool.connect();
let connection = this.connections.get(client);
if (!connection) {
connection = new DenoPostgresConnection(client);
this.connections.set(client, connection);
}
return connection;
}
async beginTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("begin"));
}
async commitTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("commit"));
}
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("rollback"));
}
async releaseConnection(connection: DatabaseConnection): Promise<void> {
(connection as DenoPostgresConnection).release();
}
async destroy(): Promise<void> {
await this.pool.end();
}
}
class DenoPostgresConnection implements DatabaseConnection {
private client: PoolClient;
constructor(client: PoolClient) {
this.client = client;
}
async executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
const result = await this.client.queryObject<O>({
text: compiledQuery.sql,
args: compiledQuery.parameters as unknown[],
});
if (
result.command === "INSERT" ||
result.command === "UPDATE" ||
result.command === "DELETE"
) {
return {
numAffectedRows: BigInt(result.rowCount ?? 0),
rows: result.rows ?? [],
};
}
return {
rows: result.rows ?? [],
};
}
streamQuery<O>(
_compiledQuery: CompiledQuery,
_chunkSize?: number,
): AsyncIterableIterator<QueryResult<O>> {
throw new Error("Deno Postgres driver does not support streaming");
}
release() {
this.client.release();
}
}
// Create connection pool
// Use SUPABASE_DB_URL if available (production), otherwise fall back to local dev connection string
const connectionString =
Deno.env.get("SUPABASE_DB_URL") ||
"postgresql://postgres:postgres@db:5432/postgres";
const pool = new Pool(connectionString, 1); // Single connection for edge functions
// Create and export Kysely instance
export const db = new Kysely<Database>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DenoPostgresDriver(pool),
createIntrospector: (db: Kysely<any>) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
});

View File

@@ -0,0 +1,15 @@
import { type User } from "jsr:@supabase/supabase-js@2";
import { supabaseAdmin } from "./supabaseAdmin.ts";
/**
* Get the sale associated to the provided user.
*/
export const getUserSale = async (user: User) => {
return (
await supabaseAdmin
.from("sales")
.select("*")
.eq("user_id", user.id)
.single()
)?.data;
};

View File

@@ -0,0 +1,15 @@
import type { SupabaseClient } from "jsr:@supabase/supabase-js@2";
import { createClient } from "jsr:@supabase/supabase-js@2";
export const supabaseAdmin: SupabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
// TODO - When Supabase finally provides the project's secret key to the functions, we'll need to use it here instead of the service role key.
// See https://supabase.com/docs/guides/functions/auth#get-api-details
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);

View File

@@ -0,0 +1,12 @@
import { corsHeaders } from "./cors.ts";
export function createErrorResponse(
status: number,
message: string,
custom: Record<string, unknown> = {},
) {
return new Response(JSON.stringify({ status, message, ...custom }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
status,
});
}

View File

@@ -0,0 +1,158 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { AuthMiddleware } from "../_shared/authentication.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
const ATTACHMENTS_BUCKET =
Deno.env.get("VITE_ATTACHMENTS_BUCKET") || "attachments";
type NoteAttachment = {
path?: string | null;
src?: string | null;
};
type NoteRecord = {
id?: number | string | null;
attachments?: NoteAttachment[] | null;
};
type WebhookPayload = {
type?: string | null;
old_record?: NoteRecord | null;
record?: NoteRecord | null;
};
const deleteNoteAttachments = async (req: Request) => {
if (req.method !== "POST") {
return jsonResponse({ error: "Method Not Allowed" }, 405);
}
const payload = (await req.json()) as WebhookPayload;
const paths = getPathsToDelete(payload);
if (paths.length === 0) {
return jsonResponse({
status: "skipped",
reason: "no_paths_to_delete",
});
}
const { error } = await supabaseAdmin.storage
.from(ATTACHMENTS_BUCKET)
.remove(paths);
if (error) {
console.error("Failed to delete note attachments", {
type: payload.type ?? null,
paths,
error,
});
return jsonResponse({ error: "Failed to delete note attachments" }, 500);
}
return jsonResponse({
status: "ok",
});
};
Deno.serve(async (req: Request) =>
AuthMiddleware(req, async (req: Request) => deleteNoteAttachments(req)),
);
const getPathsToDelete = (payload: WebhookPayload): string[] => {
const oldPaths = extractAttachmentPaths(payload.old_record?.attachments);
const newPaths = extractAttachmentPaths(payload.record?.attachments);
if (payload.type === "UPDATE") {
const newPathsSet = new Set(newPaths);
return oldPaths.filter((path) => !newPathsSet.has(path));
}
if (payload.type === "DELETE") {
return oldPaths;
}
return [];
};
const extractAttachmentPaths = (
attachments?: NoteAttachment[] | null,
): string[] => {
const paths = attachments
?.map((attachment) => extractAttachmentPath(attachment))
.filter((path): path is string => path != null && path.length > 0);
return paths ? Array.from(new Set(paths)) : [];
};
const extractAttachmentPath = (attachment?: NoteAttachment | null) => {
if (!attachment) {
return null;
}
if (attachment.path) {
return normalizeStoragePath(attachment.path);
}
if (!attachment.src) {
return null;
}
const pathname = getPathname(attachment.src);
if (!pathname) {
return null;
}
const bucketSegment = `/${ATTACHMENTS_BUCKET}/`;
const bucketIndex = pathname.lastIndexOf(bucketSegment);
if (bucketIndex < 0) {
return null;
}
const path = pathname.slice(bucketIndex + bucketSegment.length);
return normalizeStoragePath(path);
};
const getPathname = (value: string) => {
try {
return new URL(value, "http://localhost").pathname;
} catch {
return null;
}
};
const safelyDecodePath = (path: string) => {
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const normalizeStoragePath = (path: string) => {
const trimmedPath = path.trim();
if (trimmedPath.length === 0) {
return null;
}
const parsedPath = getPathname(trimmedPath);
const candidatePath = parsedPath ?? trimmedPath;
const bucketSegment = `/${ATTACHMENTS_BUCKET}/`;
const bucketIndex = candidatePath.lastIndexOf(bucketSegment);
const withoutBucket =
bucketIndex >= 0
? candidatePath.slice(bucketIndex + bucketSegment.length)
: candidatePath.replace(/^\/+/, "").replace(/^attachments\//, "");
if (withoutBucket.length === 0) {
return null;
}
return safelyDecodePath(withoutBucket);
};
const jsonResponse = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});

View File

@@ -0,0 +1,648 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { McpServer } from "npm:@modelcontextprotocol/sdk@1.28.0/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "npm:@modelcontextprotocol/sdk@1.28.0/server/webStandardStreamableHttp.js";
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "npm:jose@5";
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
import { z } from "npm:zod@^3.25";
import { validateReadOnly, validateWrite } from "./validateSql.ts";
import { TASK_LIST_HTML, TASK_LIST_UI_URI } from "./taskListUi.ts";
import { corsHeaders } from "../_shared/cors.ts";
// --- Environment & Config ---
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_JWT_ISSUER =
Deno.env.get("SB_JWT_ISSUER") ?? `${SUPABASE_URL}/auth/v1`;
const CRM_BASE_URL = (Deno.env.get("CRM_BASE_URL") ?? "").replace(/\/$/, "");
const JWKS = createRemoteJWKSet(
new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`),
);
const connectionString =
Deno.env.get("SUPABASE_DB_URL") ||
"postgresql://postgres:postgres@db:5432/postgres";
const pool = new Pool(connectionString, 1);
// --- URL Helpers ---
function getBaseUrl(req: Request): string {
const forwardedHost = req.headers.get("x-forwarded-host");
if (forwardedHost) {
// When behind a proxy (ngrok, production), always use HTTPS.
// x-forwarded-proto may not survive the Supabase gateway chain.
return `https://${forwardedHost}`;
}
const url = new URL(req.url);
const host = url.host;
// Supabase edge functions see http:// internally, but are served over HTTPS publicly
const proto =
host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
return `${proto}://${host}`;
}
function getResourceMetadataUrl(req: Request): string {
return `${getBaseUrl(req)}/functions/v1/mcp/oauth-protected-resource`;
}
// --- Auth ---
interface AuthInfo {
token: string;
userId: string;
role?: string;
clientId?: string;
}
async function validateToken(req: Request): Promise<AuthInfo | null> {
const authHeader = req.headers.get("authorization");
if (!authHeader) return null;
const [bearer, token] = authHeader.split(" ");
if (bearer !== "Bearer" || !token) return null;
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: SUPABASE_JWT_ISSUER,
});
if (!payload.sub) return null;
return {
token,
userId: payload.sub,
role: payload.role as string | undefined,
clientId: payload.client_id as string | undefined,
};
} catch {
return null;
}
}
// --- Database: get_schema ---
async function getSchemaData(): Promise<string> {
const client = await pool.connect();
try {
// Query 1: All columns from public schema
const columnsResult = await client.queryObject<{
table_name: string;
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
table_type: string;
}>(`
SELECT
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
t.table_type
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.table_schema = 'public'
ORDER BY c.table_name, c.ordinal_position
`);
// Query 2: Foreign key relationships
const fkResult = await client.queryObject<{
source_table: string;
source_column: string;
target_table: string;
target_column: string;
}>(`
SELECT
src.relname AS source_table,
src_att.attname AS source_column,
tgt.relname AS target_table,
tgt_att.attname AS target_column
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src ON con.conrelid = src.oid
JOIN pg_catalog.pg_namespace nsp ON src.relnamespace = nsp.oid
JOIN pg_catalog.pg_class tgt ON con.confrelid = tgt.oid
JOIN pg_catalog.pg_attribute src_att
ON src_att.attrelid = con.conrelid AND src_att.attnum = ANY(con.conkey)
JOIN pg_catalog.pg_attribute tgt_att
ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = ANY(con.confkey)
WHERE con.contype = 'f' AND nsp.nspname = 'public'
ORDER BY src.relname
`);
// Group columns by table
const tables = new Map<
string,
{
type: string;
columns: {
name: string;
type: string;
nullable: boolean;
default: string | null;
}[];
}
>();
for (const row of columnsResult.rows) {
if (!tables.has(row.table_name)) {
tables.set(row.table_name, {
type: row.table_type === "VIEW" ? "View" : "Table",
columns: [],
});
}
tables.get(row.table_name)!.columns.push({
name: row.column_name,
type: row.data_type,
nullable: row.is_nullable === "YES",
default: row.column_default,
});
}
// Group foreign keys by source table
const foreignKeys = new Map<
string,
{ source_column: string; target_table: string; target_column: string }[]
>();
for (const row of fkResult.rows) {
if (!foreignKeys.has(row.source_table)) {
foreignKeys.set(row.source_table, []);
}
foreignKeys.get(row.source_table)!.push({
source_column: row.source_column,
target_table: row.target_table,
target_column: row.target_column,
});
}
// Format output
const lines: string[] = [];
for (const [tableName, table] of tables) {
lines.push(`${table.type}: ${tableName}`);
for (const col of table.columns) {
const parts = [` - ${col.name}: ${col.type}`];
if (col.nullable) parts.push("(nullable)");
if (col.default) parts.push(`default: ${col.default}`);
lines.push(parts.join(" "));
}
const fks = foreignKeys.get(tableName);
if (fks && fks.length > 0) {
lines.push(" Foreign Keys:");
for (const fk of fks) {
lines.push(
` - ${fk.source_column} -> ${fk.target_table}.${fk.target_column}`,
);
}
}
lines.push("");
}
return lines.join("\n");
} finally {
client.release();
}
}
// --- Database: query with RLS ---
async function executeQueryWithRLS(
sql: string,
userToken: string,
validate: (sql: string) => string | null,
): Promise<
{ success: true; data: unknown[] } | { success: false; error: string }
> {
const validationError = validate(sql);
if (validationError) {
return { success: false, error: validationError };
}
const client = await pool.connect();
try {
const jwtClaims = decodeJwt(userToken);
const claimsJson = JSON.stringify(jwtClaims);
await client.queryObject("BEGIN");
// set_config(..., is_local=true) is the parameterized equivalent of
// SET LOCAL — avoids interpolating JWT claims into a SQL string.
await client.queryObject(
"SELECT set_config('role', 'authenticated', true)",
);
await client.queryObject({
text: "SELECT set_config('request.jwt.claims', $1, true)",
args: [claimsJson],
});
const result = await client.queryObject(sql);
await client.queryObject("COMMIT");
// Convert BigInt values to numbers (Deno Postgres returns bigint for
// PostgreSQL int8/count results, but JSON.stringify can't handle them)
const rows = JSON.parse(
JSON.stringify(result.rows, (_key, value) =>
typeof value === "bigint" ? Number(value) : value,
),
);
return { success: true, data: rows };
} catch (error) {
try {
await client.queryObject("ROLLBACK");
} catch {
// Ignore rollback errors
}
const message =
error instanceof AggregateError
? error.errors.map((e) => e.message).join("; ")
: error instanceof Error
? error.message
: String(error);
return { success: false, error: message };
} finally {
client.release();
}
}
// --- MCP Server Factory ---
function createMcpServer(authInfo: AuthInfo): McpServer {
const server = new McpServer({
name: "atomic-crm",
version: "1.0.0",
});
server.registerTool(
"get_schema",
{
title: "Get Database Schema",
description:
"Retrieve the database schema for the user's Atomic CRM instance including all tables, views, columns, types, and foreign key relationships. Views (like contacts_summary, companies_summary) are read-only and provide pre-joined/aggregated data. Use them for search and list queries.",
annotations: { readOnlyHint: true },
},
async () => {
const schema = await getSchemaData();
return { content: [{ type: "text" as const, text: schema }] };
},
);
server.registerTool(
"query",
{
title: "Query CRM Data",
description: `Read data from the user's CRM instance using SQL SELECT queries.
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
Use this tool when the user asks about their CRM data such as:
- Contacts, companies, and deals
- Sales pipeline and forecasting data
- Customer interactions and notes
- Tasks and follow-ups
- Custom fields and metadata
Row Level Security (RLS) is enforced - queries automatically return only data the authenticated user has permission to access.
Use the *_summary views (contacts_summary, companies_summary) for queries that need aggregated data or search capabilities.
To filter by the current user, if the table has a sales_id column, add a WHERE sales_id = auth.uid() clause to your query.
This tool only supports SELECT queries. For INSERT, UPDATE, or DELETE operations, use the mutate tool.
Examples:
- "SELECT id, first_name, last_name, email_fts FROM contacts_summary WHERE email_fts LIKE '%@company.com%'"
- "SELECT name, stage, amount FROM deals WHERE created_at > NOW() - INTERVAL '30 days' ORDER BY amount DESC"
- "SELECT COUNT(*) as total_tasks, type FROM tasks WHERE done_date IS NULL GROUP BY type"
- "SELECT c.first_name, c.last_name, co.name as company_name FROM contacts c JOIN companies co ON c.company_id = co.id WHERE co.sector = 'Technology'"`,
inputSchema: z.object({
sql: z.string().describe("The SQL SELECT query to execute"),
}),
annotations: { readOnlyHint: true },
},
async ({ sql }: { sql: string }) => {
// eslint-disable-next-line no-console
console.log(`[MCP query] user=${authInfo.userId} sql=${sql}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateReadOnly,
);
if (result.success) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result.data, null, 2),
},
],
};
}
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
},
);
server.registerTool(
"mutate",
{
title: "Mutate CRM Data",
description: `Create, update, or delete data in the user's CRM instance using SQL.
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
Use this tool for data modifications such as:
- Creating new contacts, companies, deals, tasks, or notes
- Updating existing records
- Deleting records
Row Level Security (RLS) is enforced - mutations only affect data the authenticated user has permission to modify.
IMPORTANT: Never specify sales_id in INSERT or UPDATE statements — it is automatically set to the authenticated user by a database trigger.
For read-only queries, use the query tool instead.
Examples:
- "INSERT INTO contacts (first_name, last_name, email) VALUES ('John', 'Doe', 'john@example.com')"
- "UPDATE deals SET stage = 'won-deal' WHERE id = 123"
- "DELETE FROM tasks WHERE id = 456"`,
inputSchema: z.object({
sql: z
.string()
.describe("The SQL INSERT, UPDATE, or DELETE statement to execute"),
}),
annotations: { destructiveHint: true },
},
async ({ sql }: { sql: string }) => {
// eslint-disable-next-line no-console
console.log(`[MCP mutate] user=${authInfo.userId} sql=${sql}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateWrite,
);
if (result.success) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result.data, null, 2),
},
],
};
}
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
},
);
// --- UI resource for the task-list MCP App ---
// Inject the CRM base URL into the task-list guest HTML
// so contact names can link back to the CRM
const taskListHtml = TASK_LIST_HTML.replace(
/__CRM_BASE_URL__/g,
CRM_BASE_URL,
);
server.registerResource(
"task-list-ui",
TASK_LIST_UI_URI,
{
title: "Task List UI",
description: "Interactive list of tasks with mark-as-done buttons.",
mimeType: "text/html;profile=mcp-app",
},
async (uri: URL) => ({
contents: [
{
uri: uri.href,
mimeType: "text/html;profile=mcp-app",
text: taskListHtml,
},
],
}),
);
const taskSchema = z.object({
id: z
.number()
.int()
.describe("Task id — required for the mark-as-done action"),
text: z.string().nullable().optional().describe("Task description"),
type: z
.string()
.nullable()
.optional()
.describe("Task category/type (rendered as a pill)"),
due_date: z.string().nullable().optional().describe("ISO date string"),
done_date: z
.string()
.nullable()
.optional()
.describe("ISO timestamp if already done; null or omitted for pending"),
contact_name: z
.string()
.nullable()
.optional()
.describe("Full name of the linked contact, if any"),
contact_id: z
.number()
.int()
.nullable()
.optional()
.describe(
"Id of the linked contact — used to render the contact name as a link to the CRM contact page",
),
});
type Task = z.infer<typeof taskSchema>;
server.registerTool(
"display_task_list",
{
title: "Display Task List",
description: `Render an array of task rows as an interactive UI (MCP App) where the user can mark each task as done.
This tool is presentational: it does not query the database. Fetch the rows yourself via the query tool (joining contacts for contact_name when useful), then pass them here. Prefer this over replying with a bulleted list of tasks.
Each task should include at least: id (required, used for the mark-as-done action), text, type, due_date, done_date, and optionally contact_name + contact_id (the UI renders the name as a link to the CRM contact page when contact_id is provided).`,
inputSchema: {
tasks: z.array(taskSchema).describe("Array of task objects to render"),
},
annotations: { readOnlyHint: true },
_meta: {
ui: {
resourceUri: TASK_LIST_UI_URI,
visibility: ["model"],
},
},
},
({ tasks }: { tasks: Task[] }) => {
// eslint-disable-next-line no-console
console.log(
`[MCP display_task_list] user=${authInfo.userId} count=${tasks.length}`,
);
// content carries the display text (used by Claude's guest HTML);
// structuredContent carries the typed data (used by ChatGPT's Apps SDK
// convention). Supplying both keeps the guest host-agnostic.
return {
content: [{ type: "text" as const, text: JSON.stringify(tasks) }],
structuredContent: { tasks },
};
},
);
server.registerTool(
"complete_task",
{
title: "Mark Task Done",
description:
"Mark a single task as done by id. Used by the task-list UI when the user clicks a task's checkmark, and also callable directly by the model.",
inputSchema: {
id: z
.number()
.int()
.positive()
.describe("The id of the task to mark as done"),
},
annotations: { idempotentHint: true },
_meta: {
ui: {
visibility: ["model", "app"],
},
},
},
async ({ id }: { id: number }) => {
// RETURNING id lets us distinguish a successful update from an
// RLS-blocked or non-existent row (executeQueryWithRLS would otherwise
// report success on 0 rows affected).
const sql = `UPDATE tasks SET done_date = NOW() WHERE id = ${id} RETURNING id`;
// eslint-disable-next-line no-console
console.log(`[MCP complete_task] user=${authInfo.userId} id=${id}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateWrite,
);
if (!result.success) {
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
}
if (result.data.length === 0) {
return {
content: [
{
type: "text" as const,
text: `Error: task ${id} not found or permission denied.`,
},
],
isError: true,
};
}
return {
content: [
{ type: "text" as const, text: `Task ${id} marked as done.` },
],
};
},
);
return server;
}
// --- OAuth Protected Resource Metadata ---
function handleProtectedResourceMetadata(req: Request): Response {
const baseUrl = getBaseUrl(req);
return new Response(
JSON.stringify({
resource: `${baseUrl}/functions/v1/mcp`,
authorization_servers: [`${baseUrl}/auth/v1`],
bearer_methods_supported: ["header"],
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// --- MCP Request Handler ---
async function handleMcpRequest(req: Request): Promise<Response> {
// Validate auth
const authInfo = await validateToken(req);
if (!authInfo) {
const metadataUrl = getResourceMetadataUrl(req);
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`,
},
});
}
// Create stateless MCP server + transport for this request
const server = createMcpServer(authInfo);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless
});
await server.connect(transport);
// Clean up server + transport when the connection closes.
// Do NOT close in a finally block — the SSE response body is a
// ReadableStream that must remain open until the client consumes it.
transport.onclose = () => {
server.close().catch(() => {});
};
try {
return await transport.handleRequest(req);
} catch (error) {
console.error("MCP request error:", error);
await transport.close();
await server.close();
return new Response("Internal Server Error", { status: 500 });
}
}
// --- CORS Helper ---
function withCorsHeaders(response: Response): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// --- Route Dispatcher ---
Deno.serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const url = new URL(req.url);
const path = url.pathname;
// GET /functions/v1/mcp/oauth-protected-resource → RFC 9728 metadata
if (path.endsWith("/oauth-protected-resource") && req.method === "GET") {
return withCorsHeaders(handleProtectedResourceMetadata(req));
}
// POST/GET/DELETE /functions/v1/mcp → MCP protocol handler
if (path.endsWith("/mcp") || path.endsWith("/mcp/")) {
return withCorsHeaders(await handleMcpRequest(req));
}
return withCorsHeaders(new Response("Not Found", { status: 404 }));
});

View File

@@ -0,0 +1,349 @@
// Task-list MCP App. Everything lives in this single file: template,
// styles, and glue. Plain vanilla JS — no external scripts, because host
// CSPs (e.g. Claude.ai) only allow 'self' + 'unsafe-inline' for scripts.
// Syntax highlighted if the IDE has the es6-string-html extension enabled.
export const TASK_LIST_UI_URI = "ui://atomic-crm/task-list";
export const TASK_LIST_HTML = /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'">
<title>Tasks</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-size:14px; background:transparent; color:#111; }
body { padding:8px 4px; overflow:hidden; }
.task { display:flex; align-items:flex-start; gap:10px; padding:10px 12px; border:1px solid rgba(128,128,128,0.25); border-radius:8px; margin-bottom:8px; background:rgba(0,0,0,0.02); transition:opacity 0.2s ease; }
.task.done { opacity:0.45; }
.task.updating .check { opacity:0.5; cursor:wait; }
.check { width:20px; height:20px; min-width:20px; margin-top:1px; border:1.5px solid #888; border-radius:999px; background:transparent; cursor:pointer; padding:0; display:inline-flex; align-items:center; justify-content:center; color:transparent; font-size:12px; line-height:1; transition:background 0.15s, border-color 0.15s, color 0.15s; }
.check:hover:not(:disabled) { border-color:#2563eb; background:rgba(37,99,235,0.12); }
.check.checked { background:#10b981; border-color:#10b981; color:white; }
.check.checked::after { content:"\\2713"; }
.task-content { flex:1; min-width:0; }
.task-text { font-weight:500; white-space:pre-wrap; word-break:break-word; }
.task-meta { margin-top:3px; font-size:12px; color:#6b7280; display:flex; flex-wrap:wrap; gap:10px; }
.pill { display:inline-block; padding:1px 6px; border-radius:4px; background:rgba(128,128,128,0.22); color:#374151; }
.contact { color:#2563eb; font-weight:500; }
a.contact { text-decoration:none; }
a.contact:hover { text-decoration:underline; }
.empty { text-align:center; color:#6b7280; padding:16px 8px; }
.error { padding:8px 10px; border-radius:6px; border:1px solid rgba(220,38,38,0.4); background:rgba(220,38,38,0.08); color:#dc2626; font-size:12px; margin-bottom:8px; }
html.dark body { color:#f5f5f5; }
html.dark .task { background:rgba(255,255,255,0.04); }
html.dark .task-meta { color:#b8b8b8; }
html.dark .pill { color:#e5e5e5; }
html.dark .contact { color:#93c5fd; }
html.dark .empty { color:#b8b8b8; }
html.dark .error { color:#f87171; }
html.dark .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
@media (prefers-color-scheme: dark) {
html:not(.light) body { color:#f5f5f5; }
html:not(.light) .task { background:rgba(255,255,255,0.04); }
html:not(.light) .task-meta { color:#b8b8b8; }
html:not(.light) .pill { color:#e5e5e5; }
html:not(.light) .contact { color:#93c5fd; }
html:not(.light) .empty { color:#b8b8b8; }
html:not(.light) .error { color:#f87171; }
html:not(.light) .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(() => {
// Injected server-side; empty string when not configured, in which case
// contact names render as plain text instead of links.
const CRM_BASE_URL = '__CRM_BASE_URL__';
// -------- JSON-RPC plumbing over postMessage --------
const pending = new Map();
const RPC_TIMEOUT_MS = 10000;
let nextId = 1;
const post = (msg) => window.parent.postMessage(msg, '*');
const rpc = (method, params) => new Promise((resolve, reject) => {
const id = nextId++;
const timer = setTimeout(() => {
if (!pending.has(id)) return;
pending.delete(id);
reject(new Error("RPC timeout after " + RPC_TIMEOUT_MS + "ms: " + method));
}, RPC_TIMEOUT_MS);
pending.set(id, { resolve, reject, timer });
post({ jsonrpc: '2.0', id, method, params });
});
// -------- State --------
let tasks = [];
let errorMsg = '';
let ready = false;
const updating = Object.create(null);
// -------- Rendering (vanilla DOM) --------
const root = document.getElementById('app');
const contactUrl = (id) =>
CRM_BASE_URL ? CRM_BASE_URL + '/#/contacts/' + id + '/show' : '';
const formatDate = (value) => {
const d = new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const el = (tag, attrs, children) => {
const node = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
const v = attrs[k];
if (v == null || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v === true ? '' : String(v));
}
}
if (children) {
for (const c of children) {
if (c == null || c === false) continue;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
}
return node;
};
const render = () => {
root.replaceChildren();
if (errorMsg) {
root.appendChild(el('div', { class: 'error', text: errorMsg }));
}
if (!ready) {
root.appendChild(el('div', { class: 'empty', text: 'Waiting for data\u2026' }));
return;
}
if (!tasks.length) {
root.appendChild(el('div', { class: 'empty', text: 'No pending tasks.' }));
return;
}
for (const t of tasks) {
const done = !!t.done_date;
const upd = !!updating[t.id];
const classes = ['task'];
if (done) classes.push('done');
if (upd) classes.push('updating');
const meta = [];
if (t.contact_name && t.contact_id && contactUrl(t.contact_id)) {
meta.push(el('a', {
class: 'contact',
href: contactUrl(t.contact_id),
target: '_blank',
rel: 'noopener noreferrer',
text: t.contact_name,
}));
} else if (t.contact_name) {
meta.push(el('span', { class: 'contact', text: t.contact_name }));
}
if (t.type) meta.push(el('span', { class: 'pill', text: t.type }));
if (t.due_date) meta.push(el('span', { text: 'Due ' + formatDate(t.due_date) }));
const checkClasses = ['check'];
if (done) checkClasses.push('checked');
const card = el('div', { class: classes.join(' ') }, [
el('button', {
type: 'button',
class: checkClasses.join(' '),
'aria-label': done ? 'Already done' : 'Mark as done',
disabled: done,
onclick: () => complete(t),
}),
el('div', { class: 'task-content' }, [
el('div', { class: 'task-text', text: t.text || '(no description)' }),
meta.length ? el('div', { class: 'task-meta' }, meta) : null,
]),
]);
root.appendChild(card);
}
};
// -------- Actions --------
const complete = (t) => {
if (t.done_date || updating[t.id]) return;
updating[t.id] = true;
render();
rpc('tools/call', { name: 'complete_task', arguments: { id: t.id } })
.then((result) => {
updating[t.id] = false;
if (result && result.isError) {
errorMsg = (result.content && result.content[0] && result.content[0].text) || 'Failed to mark task as done';
} else {
errorMsg = '';
t.done_date = new Date().toISOString();
}
render();
})
.catch((e) => {
updating[t.id] = false;
errorMsg = 'Could not mark as done: ' + ((e && e.message) || String(e));
render();
});
};
// Extract an array of tasks from a tool result, handling the multiple
// shapes hosts use. Claude sends the JSON-stringified array in
// content[0].text. ChatGPT (OpenAI Apps SDK convention) instead populates
// structuredContent with typed data — either the array directly or a
// wrapper object like { tasks: [...] }.
const extractTasks = (result) => {
if (!result) return null;
const sc = result.structuredContent;
if (Array.isArray(sc)) return sc;
if (sc && Array.isArray(sc.tasks)) return sc.tasks;
const text = result.content && result.content[0] && result.content[0].text;
if (typeof text === 'string' && text.length > 0) {
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return parsed;
if (parsed && Array.isArray(parsed.tasks)) return parsed.tasks;
} catch (_) {
// fall through
}
}
return null;
};
const applyToolResult = (result) => {
const extracted = extractTasks(result);
if (extracted) {
tasks = extracted;
errorMsg = '';
} else {
tasks = [];
errorMsg = 'Could not extract task list from tool result';
}
ready = true;
render();
};
const applyToolInput = (input) => {
const args = input && input.arguments;
if (args && Array.isArray(args.tasks)) {
tasks = args.tasks;
errorMsg = '';
ready = true;
render();
}
};
// -------- Bootstrap --------
window.addEventListener('message', (event) => {
// Origin-agnostic (the host's origin is unknown at build time);
// just assert the message came from the parent frame.
if (event.source !== window.parent) return;
const m = event.data;
if (!m || m.jsonrpc !== '2.0') return;
if (m.id !== undefined && m.method === undefined) {
const w = pending.get(m.id);
if (!w) return;
pending.delete(m.id);
clearTimeout(w.timer);
if (m.error) w.reject(new Error(m.error.message || 'RPC error'));
else w.resolve(m.result);
return;
}
if (m.method === 'ui/notifications/tool-result') {
applyToolResult(m.params);
} else if (
m.method === 'ui/notifications/tool-input-partial' ||
m.method === 'ui/notifications/tool-input'
) {
// Render progressively from streamed tool arguments so the user sees
// tasks appear as the model generates them instead of a blank wait.
// Claude uses tool-input-partial; ChatGPT streams via tool-input.
applyToolInput(m.params);
} else if (
m.method === 'ui/notifications/host-context-changed' ||
m.method === 'ui/notifications/theme'
) {
const theme = m.params && m.params.theme;
if (theme) {
const cl = document.documentElement.classList;
cl.toggle('dark', theme === 'dark');
cl.toggle('light', theme === 'light');
}
}
});
// Iframe has no intrinsic height from the host's perspective; measure our
// own content and ask the host to resize via ui/notifications/size-changed
// (per MCP Apps spec; hosts like Claude Desktop ignore non-spec ui/size).
let lastReportedHeight = 0;
const reportSize = () => {
const h = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight,
);
const height = h + 2;
if (height === lastReportedHeight) return;
lastReportedHeight = height;
post({
jsonrpc: '2.0',
method: 'ui/notifications/size-changed',
params: { width: document.documentElement.clientWidth, height },
});
};
render();
// -------- Spec-compliant MCP Apps handshake --------
// Per the MCP Apps spec, the host MUST NOT send any notification (including
// tool-result) before it receives ui/notifications/initialized from the
// view. We therefore: (1) send ui/initialize, (2) await the response,
// (3) send ui/notifications/initialized, and only then wire up the
// ResizeObserver so the host is not spammed with size reports before handshake.
rpc('ui/initialize', {
protocolVersion: '2025-06-18',
appInfo: { name: 'atomic-crm-task-list', version: '1.0.0' },
appCapabilities: {
tools: { listChanged: false },
availableDisplayModes: ['inline'],
},
})
.then((result) => {
// Apply theme from host context if provided
const theme = result && result.hostContext && result.hostContext.theme;
if (theme) {
const cl = document.documentElement.classList;
cl.toggle('dark', theme === 'dark');
cl.toggle('light', theme === 'light');
}
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(reportSize).observe(document.body);
}
})
.catch((e) => {
errorMsg = 'Failed to initialize MCP App: ' + ((e && e.message) || e);
render();
});
})();
</script>
</body>
</html>
`;

View File

@@ -0,0 +1,122 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { validateReadOnly, validateWrite } from "./validateSql";
describe("validateReadOnly", () => {
it.each([
["simple SELECT", "SELECT * FROM contacts"],
[
"SELECT with WHERE",
"SELECT id, name FROM contacts WHERE created_at > NOW() - INTERVAL '30 days'",
],
[
"SELECT with JOIN",
"SELECT c.name, co.name FROM contacts c JOIN companies co ON c.company_id = co.id",
],
[
"SELECT with subquery",
"SELECT * FROM contacts WHERE company_id IN (SELECT id FROM companies WHERE sector = 'Tech')",
],
[
"read-only CTE",
"WITH recent AS (SELECT * FROM contacts WHERE created_at > NOW() - INTERVAL '7 days') SELECT * FROM recent",
],
[
"aggregate query",
"SELECT COUNT(*) as total, type FROM tasks GROUP BY type",
],
[
"DELETE in string literal",
"SELECT * FROM contacts WHERE status = 'DELETE'",
],
[
"DROP in string literal",
"SELECT * FROM logs WHERE message = 'DROP TABLE failed'",
],
[
"UPDATE in string literal",
"SELECT * FROM contacts WHERE note = 'Please UPDATE your info'",
],
[
"DROP in block comment",
"SELECT /* DROP TABLE contacts */ * FROM contacts",
],
["DROP in line comment", "SELECT * FROM contacts -- DROP TABLE contacts"],
])("allows %s", (_label, sql) => {
expect(validateReadOnly(sql)).toBeNull();
});
it.each([
["INSERT", "INSERT INTO contacts (name) VALUES ('test')"],
["UPDATE", "UPDATE contacts SET name = 'test'"],
["DELETE", "DELETE FROM contacts WHERE id = 1"],
[
"writable CTE (DELETE)",
"WITH d AS (DELETE FROM contacts RETURNING *) SELECT * FROM d",
],
[
"writable CTE (UPDATE)",
"WITH u AS (UPDATE contacts SET name = 'x' RETURNING *) SELECT * FROM u",
],
[
"writable CTE (INSERT)",
"WITH i AS (INSERT INTO contacts (name) VALUES ('x') RETURNING *) SELECT * FROM i",
],
["DROP TABLE", "DROP TABLE contacts"],
["CREATE TABLE", "CREATE TABLE evil (id int)"],
["ALTER TABLE", "ALTER TABLE contacts ADD COLUMN x int"],
["TRUNCATE", "TRUNCATE contacts"],
["SET", "SET LOCAL role = 'postgres'"],
["DO block", "DO $$ BEGIN END $$"],
["multi-statement (SELECT; DROP)", "SELECT 1; DROP TABLE contacts"],
["multi-statement (SELECT; SET)", "SELECT 1; SET LOCAL role = 'postgres'"],
["multi-statement (two SELECTs)", "SELECT 1; SELECT 2"],
["unparseable SQL", "NOT VALID SQL %%%"],
])("rejects %s", (_label, sql) => {
expect(validateReadOnly(sql)).not.toBeNull();
});
});
describe("validateWrite", () => {
it.each([
[
"INSERT",
"INSERT INTO contacts (first_name, last_name) VALUES ('John', 'Doe')",
],
["UPDATE", "UPDATE contacts SET name = 'test' WHERE id = 1"],
["DELETE", "DELETE FROM contacts WHERE id = 1"],
[
"INSERT with RETURNING",
"INSERT INTO contacts (name) VALUES ('test') RETURNING id",
],
[
"UPDATE with subquery",
"UPDATE contacts SET company_id = (SELECT id FROM companies WHERE name = 'Acme') WHERE id = 1",
],
[
"writable CTE with INSERT",
"WITH d AS (DELETE FROM old_contacts RETURNING *) INSERT INTO archive SELECT * FROM d",
],
])("allows %s", (_label, sql) => {
expect(validateWrite(sql)).toBeNull();
});
it.each([
["standalone SELECT", "SELECT * FROM contacts"],
["DROP TABLE", "DROP TABLE contacts"],
["CREATE TABLE", "CREATE TABLE evil (id int)"],
["TRUNCATE", "TRUNCATE contacts"],
["SET", "SET LOCAL role = 'postgres'"],
[
"multi-statement (DELETE; DROP)",
"DELETE FROM contacts; DROP TABLE contacts",
],
[
"multi-statement (DELETE; SET)",
"DELETE FROM contacts; SET LOCAL role = 'postgres'",
],
["unparseable SQL", "NOT VALID SQL %%%"],
])("rejects %s", (_label, sql) => {
expect(validateWrite(sql)).not.toBeNull();
});
});

View File

@@ -0,0 +1,73 @@
import { parse, type Statement } from "npm:pgsql-ast-parser@^12";
const ALLOWED_READ_TYPES = new Set(["select", "with"]);
const ALLOWED_WRITE_TYPES = new Set(["insert", "update", "delete", "with"]);
// Collect all statement types found in a parsed AST, including inner
// statements in WITH (CTE) bindings which can contain writable DML.
function collectStatementTypes(stmts: Statement[]): Set<string> {
const types = new Set<string>();
for (const stmt of stmts) {
types.add(stmt.type);
if (stmt.type === "with" && "bind" in stmt && Array.isArray(stmt.bind)) {
for (const cte of stmt.bind) {
if (cte.statement?.type) {
types.add(cte.statement.type);
}
}
}
}
return types;
}
export function validateReadOnly(sql: string): string | null {
let stmts: Statement[];
try {
stmts = parse(sql);
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
// pgsql-ast-parser appends a parse-table dump that is useless to the
// LLM and consumes many tokens; keep the diagnostic prefix only.
const message = raw.split("Here is the state of my parse table")[0].trim();
return `Failed to parse SQL: ${message}`;
}
if (stmts.length === 0) {
return "Empty query.";
}
if (stmts.length > 1) {
return "Only a single statement is allowed.";
}
const types = collectStatementTypes(stmts);
for (const type of types) {
if (!ALLOWED_READ_TYPES.has(type)) {
return `Statement type "${type}" is not allowed in read-only queries. Use the mutate tool for data modifications.`;
}
}
return null;
}
export function validateWrite(sql: string): string | null {
let stmts: Statement[];
try {
stmts = parse(sql);
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
// pgsql-ast-parser appends a parse-table dump that is useless to the
// LLM and consumes many tokens; keep the diagnostic prefix only.
const message = raw.split("Here is the state of my parse table")[0].trim();
return `Failed to parse SQL: ${message}`;
}
if (stmts.length === 0) {
return "Empty query.";
}
if (stmts.length > 1) {
return "Only a single statement is allowed.";
}
const types = collectStatementTypes(stmts);
for (const type of types) {
if (!ALLOWED_WRITE_TYPES.has(type)) {
return `Statement type "${type}" is not allowed. Only INSERT, UPDATE, and DELETE statements are supported.`;
}
}
return null;
}

View File

@@ -0,0 +1,194 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { sql, type Selectable } from "https://esm.sh/kysely@0.27.2";
import { db, type ContactsTable, CompiledQuery } from "../_shared/db.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
type Contact = Selectable<ContactsTable>;
// Helper functions to merge arrays
function mergeArraysUnique<T>(arr1: T[], arr2: T[]): T[] {
return [...new Set([...arr1, ...arr2])];
}
function mergeObjectArraysUnique<T>(
arr1: T[],
arr2: T[],
getKey: (item: T) => string,
): T[] {
const map = new Map<string, T>();
arr1.forEach((item) => {
const key = getKey(item);
if (key) map.set(key, item);
});
arr2.forEach((item) => {
const key = getKey(item);
if (key && !map.has(key)) {
map.set(key, item);
}
});
return Array.from(map.values());
}
function mergeContactData(winner: Contact, loser: Contact) {
// Merge emails
const mergedEmails = mergeObjectArraysUnique(
winner.email_jsonb || [],
loser.email_jsonb || [],
(email: any) => email.email,
);
// Merge phones
const mergedPhones = mergeObjectArraysUnique(
winner.phone_jsonb || [],
loser.phone_jsonb || [],
(phone: any) => phone.number,
);
const selectedAvatar =
winner.avatar && winner.avatar.src ? winner.avatar : loser.avatar;
return {
avatar: selectedAvatar ? (JSON.stringify(selectedAvatar) as any) : null,
gender: winner.gender ?? loser.gender,
first_name: winner.first_name ?? loser.first_name,
last_name: winner.last_name ?? loser.last_name,
title: winner.title ?? loser.title,
company_id: winner.company_id ?? loser.company_id,
email_jsonb: JSON.stringify(mergedEmails) as any,
phone_jsonb: JSON.stringify(mergedPhones) as any,
linkedin_url: winner.linkedin_url || loser.linkedin_url,
background: winner.background ?? loser.background,
has_newsletter: winner.has_newsletter ?? loser.has_newsletter,
first_seen: winner.first_seen ?? loser.first_seen,
last_seen:
winner.last_seen && loser.last_seen
? winner.last_seen > loser.last_seen
? winner.last_seen
: loser.last_seen
: (winner.last_seen ?? loser.last_seen),
sales_id: winner.sales_id ?? loser.sales_id,
tags: mergeArraysUnique(winner.tags || [], loser.tags || []),
};
}
async function mergeContacts(
loserId: number,
winnerId: number,
userId: string,
) {
try {
return await db.transaction().execute(async (trx) => {
// Enable RLS by switching to authenticated role and setting user context
await trx.executeQuery(CompiledQuery.raw("SET LOCAL ROLE authenticated"));
await trx.executeQuery(
CompiledQuery.raw(
`SELECT set_config('request.jwt.claim.sub', '${userId}', true)`,
),
);
// 1. Fetch both contacts
const [winner, loser] = await Promise.all([
trx
.selectFrom("contacts")
.selectAll()
.where("id", "=", winnerId)
.executeTakeFirstOrThrow(),
trx
.selectFrom("contacts")
.selectAll()
.where("id", "=", loserId)
.executeTakeFirstOrThrow(),
]);
// 2. Reassign tasks from loser to winner
await trx
.updateTable("tasks")
.set({ contact_id: winnerId })
.where("contact_id", "=", loserId)
.execute();
// 3. Reassign notes from loser to winner
await trx
.updateTable("contact_notes")
.set({ contact_id: winnerId })
.where("contact_id", "=", loserId)
.execute();
// 4. Update deals - replace loserId with winnerId in contact_ids array
const deals = await trx
.selectFrom("deals")
.selectAll()
.where(sql`contact_ids @> ARRAY[${loserId}]::bigint[]`)
.execute();
for (const deal of deals) {
const newContactIds = [
...new Set(
deal.contact_ids.filter((id) => id !== loserId).concat(winnerId),
),
];
await trx
.updateTable("deals")
.set({ contact_ids: newContactIds })
.where("id", "=", deal.id)
.execute();
}
// 5. Merge and update winner contact
const mergedData = mergeContactData(winner as Contact, loser as Contact);
await trx
.updateTable("contacts")
.set(mergedData)
.where("id", "=", winnerId)
.execute();
// 6. Delete loser contact
await trx.deleteFrom("contacts").where("id", "=", loserId).execute();
return { success: true, winnerId };
});
} catch (error) {
console.error("Transaction failed:", error);
throw error;
}
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
// Handle POST request
if (req.method === "POST") {
try {
const { loserId, winnerId } = await req.json();
if (!loserId || !winnerId) {
return createErrorResponse(400, "Missing loserId or winnerId");
}
const result = await mergeContacts(loserId, winnerId, user.id);
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error) {
console.error("Merge failed:", error);
return createErrorResponse(
500,
`Failed to merge contacts: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

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",
];

View File

@@ -0,0 +1,38 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
async function updatePassword(user: any) {
const { data, error } = await supabaseAdmin.auth.resetPasswordForEmail(
user.email,
);
if (!data || error) {
return createErrorResponse(500, "Internal Server Error");
}
return new Response(
JSON.stringify({
data,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
if (req.method === "PATCH") {
return updatePassword(user);
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

View File

@@ -0,0 +1,435 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
import { getUserSale } from "../_shared/getUserSale.ts";
import {
findInvalidEmail,
MAX_SECONDARY_EMAILS,
normalizeSecondaryEmails,
} from "./secondaryEmails.ts";
async function updateSaleDisabled(user_id: string, disabled: boolean) {
return await supabaseAdmin
.from("sales")
.update({ disabled: disabled ?? false })
.eq("user_id", user_id);
}
async function updateSaleAdministrator(
user_id: string,
administrator: boolean,
) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.update({ administrator })
.eq("user_id", user_id)
.select("*");
if (!sales?.length || salesError) {
console.error("Error updating user:", salesError);
throw salesError ?? new Error("Failed to update sale");
}
return sales.at(0);
}
async function createSale(
user_id: string,
data: {
email: string;
password: string;
first_name: string;
last_name: string;
disabled: boolean;
administrator: boolean;
},
) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.insert({ ...data, user_id })
.select("*");
if (!sales?.length || salesError) {
console.error("Error creating user:", salesError);
throw salesError ?? new Error("Failed to create sale");
}
return sales.at(0);
}
async function findEmailUsedByAnotherSale(
emails: string[],
excludeSalesId?: number,
) {
const isTakenBy = async (
column: "email" | "secondary_emails",
email: string,
) => {
const selected = supabaseAdmin.from("sales").select("id");
let query =
column === "email"
? selected.eq("email", email)
: selected.contains("secondary_emails", JSON.stringify([email]));
if (excludeSalesId !== undefined) {
query = query.neq("id", excludeSalesId);
}
const { data: matches, error: salesError } = await query.limit(1);
if (salesError) {
console.error("Error fetching sales:", salesError);
throw salesError;
}
return Boolean(matches?.length);
};
for (const email of emails) {
if (
(await isTakenBy("email", email)) ||
(await isTakenBy("secondary_emails", email))
) {
return email;
}
}
return undefined;
}
async function updateSaleSecondaryEmails(
user_id: string,
secondary_emails: string[],
) {
const { error: salesError } = await supabaseAdmin
.from("sales")
.update({ secondary_emails })
.eq("user_id", user_id);
if (salesError) {
console.error("Error updating user:", salesError);
throw salesError;
}
}
async function updateSaleAvatar(user_id: string, avatar: string) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.update({ avatar })
.eq("user_id", user_id)
.select("*");
if (!sales?.length || salesError) {
console.error("Error updating user:", salesError);
throw salesError ?? new Error("Failed to update sale");
}
return sales.at(0);
}
async function inviteUser(req: Request, currentUserSale: any) {
const { email, password, first_name, last_name, disabled, administrator } =
await req.json();
if (!currentUserSale.administrator) {
return createErrorResponse(401, "Not Authorized");
}
const takenEmail = await findEmailUsedByAnotherSale(
email ? [email.trim().toLowerCase()] : [],
);
if (takenEmail) {
return createErrorResponse(
409,
`Email already used by another user: ${takenEmail}`,
{ code: "email_taken", email: takenEmail },
);
}
const { data, error: userError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
user_metadata: { first_name, last_name },
});
let user = data?.user;
if (!user && userError?.code === "email_exists") {
// This may happen if users cleared their database but not the users
// We have to create the sale directly
const { data, error } = await supabaseAdmin.rpc("get_user_id_by_email", {
email,
});
if (!data || error) {
console.error(
`Error inviting user: error=${error ?? "could not fetch users for email"}`,
);
return createErrorResponse(500, "Internal Server Error");
}
user = data[0];
try {
const { data: existingSale, error: salesError } = await supabaseAdmin
.from("sales")
.select("*")
.eq("user_id", user.id);
if (salesError) {
return createErrorResponse(salesError.status, salesError.message, {
code: salesError.code,
});
}
if (existingSale.length > 0) {
return createErrorResponse(
400,
"A sales for this email already exists",
);
}
const sale = await createSale(user.id, {
email,
password,
first_name,
last_name,
disabled,
administrator,
});
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
} catch (error) {
return createErrorResponse(
(error as any).status ?? 500,
(error as Error).message,
{
code: (error as any).code,
},
);
}
} else {
if (userError) {
console.error(`Error inviting user: user_error=${userError}`);
return createErrorResponse(userError.status, userError.message, {
code: userError.code,
});
}
if (!data?.user) {
console.error("Error inviting user: undefined user");
return createErrorResponse(500, "Internal Server Error");
}
const { error: emailError } =
await supabaseAdmin.auth.admin.inviteUserByEmail(email);
if (emailError) {
console.error(`Error inviting user, email_error=${emailError}`);
return createErrorResponse(500, "Failed to send invitation mail");
}
}
try {
await updateSaleDisabled(user.id, disabled);
const sale = await updateSaleAdministrator(user.id, administrator);
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
}
async function patchUser(req: Request, currentUserSale: any) {
const {
sales_id,
email,
secondary_emails,
first_name,
last_name,
avatar,
administrator,
disabled,
} = await req.json();
const { data: sale } = await supabaseAdmin
.from("sales")
.select("*")
.eq("id", sales_id)
.single();
if (!sale) {
return createErrorResponse(404, "Not Found");
}
// Users can only update their own profile unless they are an administrator
if (!currentUserSale.administrator && currentUserSale.id !== sale.id) {
return createErrorResponse(401, "Not Authorized");
}
if (email && email.trim().toLowerCase() !== sale.email.toLowerCase()) {
const takenEmail = await findEmailUsedByAnotherSale(
[email.trim().toLowerCase()],
sales_id,
);
if (takenEmail) {
return createErrorResponse(
409,
`Email already used by another user: ${takenEmail}`,
{ code: "email_taken", email: takenEmail },
);
}
}
let normalizedSecondaryEmails: string[] | undefined;
if (secondary_emails !== undefined) {
normalizedSecondaryEmails = normalizeSecondaryEmails(secondary_emails);
if (!normalizedSecondaryEmails) {
return createErrorResponse(400, "secondary_emails must be an array", {
code: "invalid_secondary_emails_payload",
});
}
if (normalizedSecondaryEmails.length > MAX_SECONDARY_EMAILS) {
return createErrorResponse(
400,
`No more than ${MAX_SECONDARY_EMAILS} secondary emails are allowed`,
{ code: "too_many_secondary_emails" },
);
}
const invalidEmail = findInvalidEmail(normalizedSecondaryEmails);
if (invalidEmail) {
return createErrorResponse(
400,
`Invalid secondary email: ${invalidEmail}`,
{ code: "invalid_secondary_email", email: invalidEmail },
);
}
const takenEmail = await findEmailUsedByAnotherSale(
normalizedSecondaryEmails,
sales_id,
);
if (takenEmail) {
return createErrorResponse(
409,
`Secondary email already used by another user: ${takenEmail}`,
{ code: "secondary_email_taken", email: takenEmail },
);
}
}
const primaryEmail = (email ?? sale.email).trim().toLowerCase();
const effectiveSecondaryEmails =
normalizedSecondaryEmails ??
(Array.isArray(sale.secondary_emails) ? sale.secondary_emails : []);
if (effectiveSecondaryEmails.includes(primaryEmail)) {
return createErrorResponse(
409,
`Secondary email is already the main address: ${primaryEmail}`,
{ code: "secondary_email_is_primary", email: primaryEmail },
);
}
const { data, error: userError } =
await supabaseAdmin.auth.admin.updateUserById(sale.user_id, {
email,
ban_duration: disabled ? "87600h" : "none",
user_metadata: { first_name, last_name },
});
if (!data?.user || userError) {
console.error("Error patching user:", userError);
return createErrorResponse(500, "Internal Server Error");
}
try {
if (avatar) {
await updateSaleAvatar(data.user.id, avatar);
}
if (normalizedSecondaryEmails) {
await updateSaleSecondaryEmails(data.user.id, normalizedSecondaryEmails);
}
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
// Only administrators can update the administrator and disabled status
if (!currentUserSale.administrator) {
const { data: new_sale } = await supabaseAdmin
.from("sales")
.select("*")
.eq("id", sales_id)
.single();
return new Response(
JSON.stringify({
data: new_sale,
}),
{
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
},
);
}
try {
await updateSaleDisabled(data.user.id, disabled);
const sale = await updateSaleAdministrator(data.user.id, administrator);
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
},
);
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
const currentUserSale = await getUserSale(user);
if (!currentUserSale) {
return createErrorResponse(401, "Unauthorized");
}
try {
if (req.method === "POST") {
return await inviteUser(req, currentUserSale);
}
if (req.method === "PATCH") {
return await patchUser(req, currentUserSale);
}
} catch (e) {
console.error("Unhandled error in the users function:", e);
return createErrorResponse(500, "Internal Server Error");
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

View File

@@ -0,0 +1,74 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { findInvalidEmail, normalizeSecondaryEmails } from "./secondaryEmails";
describe("normalizeSecondaryEmails", () => {
it("returns undefined for a non-array, so the caller rejects instead of wiping", () => {
expect(normalizeSecondaryEmails(undefined)).toBe(undefined);
expect(normalizeSecondaryEmails(null)).toBe(undefined);
expect(normalizeSecondaryEmails("john@perso.com")).toBe(undefined);
expect(normalizeSecondaryEmails({ 0: "john@perso.com" })).toBe(undefined);
});
it("returns an empty array for an empty array, which clears the addresses", () => {
expect(normalizeSecondaryEmails([])).toEqual([]);
});
it("trims and lowercases every address", () => {
expect(normalizeSecondaryEmails([" John@Perso.COM "])).toEqual([
"john@perso.com",
]);
});
it("removes addresses that differ only by case or spacing", () => {
expect(
normalizeSecondaryEmails(["john@perso.com", " JOHN@perso.com "]),
).toEqual(["john@perso.com"]);
});
it("drops empty entries and non-string values", () => {
expect(
normalizeSecondaryEmails(["john@perso.com", "", " ", 42, null]),
).toEqual(["john@perso.com"]);
});
it("preserves the order of the remaining addresses", () => {
expect(
normalizeSecondaryEmails(["b@perso.com", "a@perso.com", "c@perso.com"]),
).toEqual(["b@perso.com", "a@perso.com", "c@perso.com"]);
});
});
describe("findInvalidEmail", () => {
it("returns undefined when every address is well formed", () => {
expect(
findInvalidEmail(["john@perso.com", "j.doe+tag@sub.domain.co.uk"]),
).toBe(undefined);
});
it("returns undefined for an empty list", () => {
expect(findInvalidEmail([])).toBe(undefined);
});
it("reports an address with no domain", () => {
expect(findInvalidEmail(["john@perso.com", "not-an-email"])).toBe(
"not-an-email",
);
});
it("reports an address with no top level domain", () => {
expect(findInvalidEmail(["john@localhost"])).toBe("john@localhost");
});
it("reports an address containing a space", () => {
expect(findInvalidEmail(["john doe@perso.com"])).toBe("john doe@perso.com");
});
it("reports the first offending address so the caller can name it", () => {
expect(findInvalidEmail(["bad-one", "bad-two"])).toBe("bad-one");
});
it("rejects a comma, which would split the PostgREST or() filter", () => {
expect(findInvalidEmail(["john,doe@perso.com"])).toBe("john,doe@perso.com");
});
});

View File

@@ -0,0 +1,22 @@
const EMAIL_PATTERN = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
export const MAX_SECONDARY_EMAILS = 10;
export const normalizeSecondaryEmails = (
secondaryEmails: unknown,
): string[] | undefined => {
if (!Array.isArray(secondaryEmails)) {
return undefined;
}
return [
...new Set(
secondaryEmails
.filter((email): email is string => typeof email === "string")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
),
];
};
export const findInvalidEmail = (emails: string[]): string | undefined =>
emails.find((email) => !EMAIL_PATTERN.test(email));