747 lines
29 KiB
TypeScript
747 lines
29 KiB
TypeScript
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
import type { Quote, Customer, Company } from "@/lib/types";
|
|
|
|
/**
|
|
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
|
|
*
|
|
* This module is the canonical home for all email-to-quote business logic.
|
|
* It deliberately avoids the pitfalls of the old Django implementation:
|
|
*
|
|
* - No fuzzy customer/company matching — only exact + normalized.
|
|
* - No fabricated cost or selling prices on AI-extracted items.
|
|
* - No racy `last + 1` quote number generation — uses a PocketBase-side
|
|
* atomic counter.
|
|
* - Replies are written to the `quote_replies` collection, never to a
|
|
* text blob on `quotes.notes`.
|
|
* - Idempotency: the same email (by Message-ID) cannot create two quotes.
|
|
*/
|
|
|
|
const QUOTES_COLLECTION = "quotes";
|
|
const QUOTE_ITEMS_COLLECTION = "quote_items";
|
|
const QUOTE_ATTACHMENTS_COLLECTION = "quote_attachments";
|
|
const QUOTE_REPLIES_COLLECTION = "quote_replies";
|
|
const QUOTE_COUNTERS_COLLECTION = "quote_counters";
|
|
const CUSTOMERS_COLLECTION = "customers";
|
|
const COMPANIES_COLLECTION = "companies";
|
|
|
|
const QUOTE_NUMBER_REGEX = /(?:Re:\s*)?(?:Quotation|Quote)\s+Q-(\d{4})-(\d{2})-(\d{3})/i;
|
|
|
|
/**
|
|
* Sentinel customer for emails with no From: header. PB enforces
|
|
* `required=true` on `quotes.customer`, so we can't pass null. Instead,
|
|
* every email intake with an empty `from_email` is linked to this single
|
|
* stable customer. The quoter reassigns in the quote-editing UI if/when
|
|
* they know who the real sender is.
|
|
*
|
|
* The email domain `no-from.wfdash.local` is impossible to confuse with a
|
|
* real address; the description makes the role explicit.
|
|
*/
|
|
const UNKNOWN_SENDER_CUSTOMER_EMAIL = "unknown-sender@no-from.wfdash.local";
|
|
const UNKNOWN_SENDER_CUSTOMER_NAME = "Unknown Sender";
|
|
const UNKNOWN_SENDER_CUSTOMER_DESCRIPTION =
|
|
"Auto-created sentinel for emails with no From: header. The quoter reassigns in the quote-editing UI.";
|
|
|
|
export interface EmailIntakeAttachment {
|
|
filename: string;
|
|
content_type?: string;
|
|
size?: number;
|
|
/** Raw bytes encoded as base64 (no `data:` URL prefix). */
|
|
base64: string;
|
|
}
|
|
|
|
export interface EmailIntakeExtractedItem {
|
|
description?: string;
|
|
quantity?: number;
|
|
unit?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface EmailIntakePayload {
|
|
from_email: string;
|
|
from_name?: string;
|
|
subject: string;
|
|
body_plain?: string;
|
|
body_html?: string;
|
|
/** RFC 5322 Message-ID. Required for dedup. */
|
|
message_id: string;
|
|
in_reply_to?: string;
|
|
received_at?: string;
|
|
extracted_items?: EmailIntakeExtractedItem[];
|
|
attachments?: EmailIntakeAttachment[];
|
|
/** Optional override for company matching — used by the AI extraction layer. */
|
|
company_hint?: string;
|
|
}
|
|
|
|
export interface EmailReplyPayload {
|
|
from_email: string;
|
|
from_name?: string;
|
|
subject: string;
|
|
body_plain?: string;
|
|
body_html?: string;
|
|
message_id?: string;
|
|
in_reply_to?: string;
|
|
received_at?: string;
|
|
quoted_text?: string;
|
|
}
|
|
|
|
export interface CreateQuoteFromEmailResult {
|
|
quote: Quote;
|
|
customer: Customer;
|
|
company: Company | null;
|
|
items: number;
|
|
attachments: number;
|
|
deduped: boolean;
|
|
quote_number: string;
|
|
}
|
|
|
|
export interface AppendReplyResult {
|
|
quote: Quote | null;
|
|
reply: Record<string, unknown> | null;
|
|
matched_by: "in_reply_to" | "subject" | "none";
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Atomic quote number generation
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generates the next quote number (`Q-YYYY-MM-NNN`) atomically.
|
|
*
|
|
* Uses the `quote_counters` collection with a unique `(year, month)` index.
|
|
* The `last_seq+` increment operator is evaluated server-side by PocketBase
|
|
* against the current row state, so concurrent callers cannot collide even
|
|
* if they read the counter at the same instant.
|
|
*/
|
|
export async function generateQuoteNumberServer(): Promise<string> {
|
|
const pb = await getAdminPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = now.getMonth() + 1;
|
|
|
|
const counter = await getOrCreateCounter(pb, year, month);
|
|
const updated = await pb.collection(QUOTE_COUNTERS_COLLECTION).update(counter.id, {
|
|
"last_seq+": 1,
|
|
});
|
|
|
|
return formatQuoteNumber(year, month, updated.last_seq);
|
|
}
|
|
|
|
async function getOrCreateCounter(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
year: number,
|
|
month: number
|
|
) {
|
|
try {
|
|
return await pb
|
|
.collection(QUOTE_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
} catch (err) {
|
|
// First request for this month — create the counter. If a concurrent
|
|
// caller already created it, swallow the unique-constraint error and
|
|
// re-fetch.
|
|
try {
|
|
return await pb.collection(QUOTE_COUNTERS_COLLECTION).create({
|
|
year,
|
|
month,
|
|
last_seq: 0,
|
|
});
|
|
} catch {
|
|
return await pb
|
|
.collection(QUOTE_COUNTERS_COLLECTION)
|
|
.getFirstListItem(`year=${year} && month=${month}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatQuoteNumber(year: number, month: number, seq: number): string {
|
|
return `Q-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Customer matching
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Finds an existing customer by exact email match, or creates a new one.
|
|
*
|
|
* Looks up `email`, then `alternative_email`, then `buyer_email` — case
|
|
* insensitive but otherwise an exact equality match. If no match, creates a
|
|
* new customer with a `description` flag marking it as auto-created from
|
|
* email intake (for audit).
|
|
*
|
|
* Never fuzzy-merges. Two customers with the same domain are kept separate
|
|
* until a human reviews them.
|
|
*/
|
|
export async function findOrCreateCustomer(input: {
|
|
email: string;
|
|
name?: string;
|
|
companyHint?: string;
|
|
}): Promise<{ customer: Customer; created: boolean }> {
|
|
const pb = await getAdminPocketBase();
|
|
const email = (input.email || "").trim().toLowerCase();
|
|
if (!email) {
|
|
throw new Error("Customer email is required for findOrCreateCustomer.");
|
|
}
|
|
|
|
const exact = await findCustomerByExactEmail(pb, email);
|
|
if (exact) {
|
|
return { customer: exact, created: false };
|
|
}
|
|
|
|
const senderDomain = email.split("@")[1] || "";
|
|
const fallbackName = input.name?.trim() || senderDomain || email;
|
|
const fallbackCompany = input.companyHint?.trim() || senderDomain;
|
|
|
|
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
|
|
customer: fallbackName,
|
|
email,
|
|
company: fallbackCompany,
|
|
dateadded: new Date().toISOString().slice(0, 10),
|
|
description: "Auto-created from email intake.",
|
|
});
|
|
return { customer: created as Customer, created: true };
|
|
}
|
|
|
|
/**
|
|
* Returns the singleton "Unknown Sender" customer used when an inbound email
|
|
* has no From: header. Created on first use, reused for all subsequent
|
|
* unknown-sender emails. The race-safety pattern (try-create, re-fetch on
|
|
* unique-index failure) mirrors `generateQuoteNumberServer`.
|
|
*/
|
|
async function findOrCreateUnknownSenderCustomer(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>
|
|
): Promise<{ customer: Customer; created: boolean }> {
|
|
const existing = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
|
|
if (existing) {
|
|
return { customer: existing, created: false };
|
|
}
|
|
try {
|
|
const created = await pb.collection(CUSTOMERS_COLLECTION).create({
|
|
customer: UNKNOWN_SENDER_CUSTOMER_NAME,
|
|
email: UNKNOWN_SENDER_CUSTOMER_EMAIL,
|
|
company: "Unknown",
|
|
dateadded: new Date().toISOString().slice(0, 10),
|
|
description: UNKNOWN_SENDER_CUSTOMER_DESCRIPTION,
|
|
});
|
|
return { customer: created as Customer, created: true };
|
|
} catch (err) {
|
|
// Race: a concurrent intake created the sentinel first. Re-fetch.
|
|
const refetched = await findCustomerByExactEmail(pb, UNKNOWN_SENDER_CUSTOMER_EMAIL);
|
|
if (refetched) {
|
|
return { customer: refetched, created: false };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function findCustomerByExactEmail(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
email: string
|
|
): Promise<Customer | null> {
|
|
const esc = email.replace(/"/g, '\\"');
|
|
// Try the primary email field first, then the two alternates. Each is a
|
|
// separate query — cheap because of PocketBase's text index.
|
|
for (const field of ["email", "alternative_email", "buyer_email"] as const) {
|
|
try {
|
|
const hit = await pb.collection(CUSTOMERS_COLLECTION).getFirstListItem(`${field} = "${esc}"`);
|
|
if (hit) return hit as Customer;
|
|
} catch {
|
|
// 404 — keep looking.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Company matching
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Finds an existing company by normalized name, or creates a new one.
|
|
*
|
|
* Two-step match:
|
|
* 1. Case-insensitive exact match on `company` field.
|
|
* 2. Normalized match (lowercase, trim, strip common suffixes) on `company`.
|
|
*
|
|
* If neither matches, creates a new record. Never fuzzy-merges.
|
|
*/
|
|
export async function findOrCreateCompany(input: {
|
|
name?: string;
|
|
address?: string;
|
|
}): Promise<{ company: Company | null; created: boolean }> {
|
|
const pb = await getAdminPocketBase();
|
|
const name = (input.name || "").trim();
|
|
if (!name) {
|
|
return { company: null, created: false };
|
|
}
|
|
|
|
const exactHit = await findCompanyByCaseInsensitiveExact(pb, name);
|
|
if (exactHit) return { company: exactHit, created: false };
|
|
|
|
const normalizedHit = await findCompanyByNormalized(pb, name);
|
|
if (normalizedHit) return { company: normalizedHit, created: false };
|
|
|
|
const created = await pb.collection(COMPANIES_COLLECTION).create({
|
|
company: name,
|
|
address: input.address || "",
|
|
vendor: "",
|
|
});
|
|
return { company: created as Company, created: true };
|
|
}
|
|
|
|
async function findCompanyByCaseInsensitiveExact(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
name: string
|
|
): Promise<Company | null> {
|
|
try {
|
|
const hit = await pb
|
|
.collection(COMPANIES_COLLECTION)
|
|
.getFirstListItem(`company = "${name.replace(/"/g, '\\"')}"`);
|
|
return hit as Company;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findCompanyByNormalized(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
name: string
|
|
): Promise<Company | null> {
|
|
const normalized = normalizeCompanyName(name);
|
|
if (!normalized) return null;
|
|
const all = await pb.collection(COMPANIES_COLLECTION).getFullList<Company>({ fields: "id,company" });
|
|
for (const c of all) {
|
|
if (normalizeCompanyName(c.company) === normalized) {
|
|
return c;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Normalizes a company name for comparison: lowercase, trim, collapse spaces,
|
|
* strip common legal suffixes, strip trailing punctuation. Intentionally
|
|
* conservative — only strips things that are unambiguously noise.
|
|
*/
|
|
export function normalizeCompanyName(input: string): string {
|
|
return (input || "")
|
|
.toLowerCase()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/[.,]+$/g, "")
|
|
.replace(
|
|
/\b(pty ltd|proprietary limited|ltd|limited|inc|incorporated|llc|corp|corporation|holdings|group|co\.?za|company)\b\.?/gi,
|
|
""
|
|
)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Quote creation from email
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Creates a Quote (and customer, company, items, attachments) from a parsed
|
|
* email payload. Idempotent on `message_id`.
|
|
*
|
|
* The human-opt-in pattern: AI-extracted items are created with
|
|
* `included_in_quote = false` and all prices zero. A human must review and
|
|
* opt each line in via the existing quote processing UI. We never fabricate
|
|
* cost or selling prices.
|
|
*/
|
|
export async function createQuoteFromEmail(
|
|
payload: EmailIntakePayload
|
|
): Promise<CreateQuoteFromEmailResult> {
|
|
// The n8n Parse Email node stamps `(no subject)` / `(no body)` placeholders
|
|
// when an inbound email is missing those headers (a malformed / RFC 5321-
|
|
// violating send). We accept whatever string the caller sends, including
|
|
// the placeholder, rather than 500ing — the office can then see the
|
|
// placeholder quote in the queue and triage manually. The type contract
|
|
// still says `subject: string`, and the n8n side is now contractually
|
|
// obligated to always send a non-empty string.
|
|
|
|
// Some senders (notably iCloud/Mail.app) strip the RFC 5322 Message-ID
|
|
// header. We still need a stable dedup key, so synthesize one from the
|
|
// real email identity. The `synth-` prefix and `@wfdash.local` suffix
|
|
// make it visually obvious in PocketBase that this isn't a real header.
|
|
const effectiveMessageId = payload.message_id?.trim() || synthesizeMessageId(payload);
|
|
|
|
// Some senders also strip the From: header entirely. PB enforces
|
|
// `required=true` on `quotes.customer`, so an empty `from_email` cannot
|
|
// be passed through as `null`. We compute a single `fromEmail` value and
|
|
// fall back to a sentinel "Unknown Sender" customer at the resolution
|
|
// step below. The `email_sender` field on the quote stores the literal
|
|
// empty string, not a placeholder — the sentinel is only used for the
|
|
// relation.
|
|
const fromEmail = (payload.from_email || "").trim().toLowerCase();
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
// ── 1. Idempotency: if we already have a quote for this Message-ID, return it.
|
|
const existing = await findQuoteByMessageId(pb, effectiveMessageId);
|
|
if (existing) {
|
|
const itemsCount = await countQuoteItems(pb, existing.id);
|
|
const attsCount = await countQuoteAttachments(pb, existing.id);
|
|
const customer = existing.customer
|
|
? ((await pb.collection(CUSTOMERS_COLLECTION).getOne(existing.customer)) as Customer)
|
|
: null;
|
|
const company = existing.company
|
|
? ((await pb.collection(COMPANIES_COLLECTION).getOne(existing.company)) as Company)
|
|
: null;
|
|
return {
|
|
quote: existing,
|
|
customer: customer as Customer,
|
|
company: company as Company | null,
|
|
items: itemsCount,
|
|
attachments: attsCount,
|
|
deduped: true,
|
|
quote_number: existing.quote_number,
|
|
};
|
|
}
|
|
|
|
// ── 2. Resolve or create the customer. Empty from_email → sentinel.
|
|
const { customer } = fromEmail
|
|
? await findOrCreateCustomer({
|
|
email: fromEmail,
|
|
name: payload.from_name,
|
|
companyHint: payload.company_hint,
|
|
})
|
|
: await findOrCreateUnknownSenderCustomer(pb);
|
|
|
|
// ── 3. Resolve or create the company. Only the AI's `company_hint` is
|
|
// trusted — the subject is never a fallback, because unparseable emails
|
|
// would otherwise create junk `companies` rows. The quoter picks the
|
|
// company in the form when the AI couldn't find one.
|
|
const { company } = await findOrCreateCompany({
|
|
name: payload.company_hint,
|
|
});
|
|
|
|
// ── 4. Generate a quote number.
|
|
const quoteNumber = await generateQuoteNumberServer();
|
|
|
|
// ── 5. Create the quote.
|
|
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
|
|
const quote = await pb.collection(QUOTES_COLLECTION).create({
|
|
quote_number: quoteNumber,
|
|
customer: customer.id,
|
|
company: company?.id || null,
|
|
description: payload.subject,
|
|
status: "new",
|
|
notes: "",
|
|
email_sender: fromEmail,
|
|
email_subject: payload.subject,
|
|
email_body: payload.body_plain || payload.body_html || "",
|
|
has_attachments: (payload.attachments?.length ?? 0) > 0,
|
|
email_message_id: effectiveMessageId,
|
|
company_letterhead: "CNL",
|
|
});
|
|
|
|
// ── 6. Pre-populate quote items from the AI extraction. Human opt-in only.
|
|
const items = (payload.extracted_items || []).filter((i) => (i.description || "").trim());
|
|
for (const item of items) {
|
|
await pb.collection(QUOTE_ITEMS_COLLECTION).create({
|
|
quote: quote.id,
|
|
description: item.description,
|
|
quote_number: quoteNumber,
|
|
quantity: Number.isFinite(item.quantity) && (item.quantity as number) > 0 ? item.quantity : 1,
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
notes: item.notes || "",
|
|
is_processed: false,
|
|
included_in_quote: false,
|
|
});
|
|
}
|
|
|
|
// ── 7. Attachments are uploaded in a follow-up step, NOT here.
|
|
//
|
|
// The previous design embedded all attachments in the single
|
|
// `quotes.create_from_email` POST as base64 in the JSON body. A single
|
|
// 5 MB PDF became ~6.7 MB of base64 in one HTTP request, which the
|
|
// Next.js route handler rejected at its default 1 MB body limit, so
|
|
// the whole create call failed and no quote row was written.
|
|
//
|
|
// The n8n pipeline now calls `quotes.create_from_email` (which creates
|
|
// the quote row + items) and then loops over the attachments, calling
|
|
// `attachments.upload_for_quote` once per file with a small per-call
|
|
// body. Failures are isolated per attachment and don't roll back the
|
|
// quote.
|
|
//
|
|
// We deliberately accept `attachments` in the payload for back-compat
|
|
// with any caller that still sends them, but we no longer act on them
|
|
// here. The result's `attachments` count is always 0 from this path.
|
|
|
|
return {
|
|
quote: quote as Quote,
|
|
customer,
|
|
company: company ?? null,
|
|
items: items.length,
|
|
attachments: 0,
|
|
deduped: false,
|
|
quote_number: quoteNumber,
|
|
};
|
|
}
|
|
|
|
async function uploadQuoteAttachment(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
input: {
|
|
quoteId: string;
|
|
filename: string;
|
|
contentType?: string;
|
|
size?: number;
|
|
base64: string;
|
|
messageId: string;
|
|
}
|
|
): Promise<Record<string, unknown>> {
|
|
const buffer = Buffer.from(input.base64, "base64");
|
|
const file = new File([buffer], input.filename, {
|
|
type: input.contentType || "application/octet-stream",
|
|
});
|
|
// The `quote_attachments.uploaded_at` date field is declared as
|
|
// onCreate-auto in `docs/pocketbase/quote_attachments.collection.json`,
|
|
// but the live PB collection in production (set up before the docs
|
|
// schema was finalised) doesn't carry that auto-fill behaviour, and
|
|
// PB rejects the create with `validation_required` if we don't send a
|
|
// value. We send `new Date().toISO()` so the field is populated for
|
|
// both schemas; PB ignores it on the doc schema and stores it on the
|
|
// live one. Cheap and forward-compatible.
|
|
const created = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).create({
|
|
quote: input.quoteId,
|
|
filename: input.filename,
|
|
file,
|
|
content_type: input.contentType || "application/octet-stream",
|
|
size_bytes: input.size ?? buffer.length,
|
|
email_message_id: input.messageId,
|
|
kind: classifyAttachment(input.contentType, input.filename),
|
|
uploaded_at: new Date().toISOString(),
|
|
});
|
|
|
|
// Flip the parent quote's `has_attachments` flag so the office sees
|
|
// the "📎" badge on the quote list. We update unconditionally rather
|
|
// than reading first — `has_attachments` is a bool column, so
|
|
// setting it to `true` on every call is idempotent and avoids a
|
|
// round-trip per attachment.
|
|
try {
|
|
await pb.collection("quotes").update(input.quoteId, { has_attachments: true });
|
|
} catch (e) {
|
|
// Non-fatal: the attachment row is the source of truth, and the
|
|
// flag will get re-synced on the next upload. Log but don't throw.
|
|
console.warn(
|
|
`[uploadQuoteAttachment] failed to set has_attachments=true on quote ${input.quoteId}:`,
|
|
(e as Error)?.message || e
|
|
);
|
|
}
|
|
|
|
return created as Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Public entry point used by the `attachments.upload_for_quote` MCP tool.
|
|
* Uploads a single attachment to the `quote_attachments` collection, linked
|
|
* to an existing quote. The caller (n8n) is expected to call this once per
|
|
* attachment after `quotes.create_from_email` returns the new quote id.
|
|
*
|
|
* Errors propagate (the MCP tool surfaces them) so the office sees which
|
|
* files didn't make it. A failed attachment does NOT roll back the quote —
|
|
* the quote is more valuable than a single bad file, and a quoter can
|
|
* always re-upload the missing file via a future API.
|
|
*/
|
|
export async function uploadAttachmentForQuote(input: {
|
|
quoteId: string;
|
|
filename: string;
|
|
contentType?: string;
|
|
size?: number;
|
|
base64: string;
|
|
messageId?: string;
|
|
}): Promise<Record<string, unknown>> {
|
|
if (!input.quoteId) throw new Error("quoteId is required.");
|
|
if (!input.filename) throw new Error("filename is required.");
|
|
if (!input.base64) throw new Error("base64 is required.");
|
|
const pb = await getAdminPocketBase();
|
|
return uploadQuoteAttachment(pb, {
|
|
quoteId: input.quoteId,
|
|
filename: input.filename,
|
|
contentType: input.contentType,
|
|
size: input.size,
|
|
base64: input.base64,
|
|
messageId: input.messageId || "",
|
|
});
|
|
}
|
|
|
|
function classifyAttachment(contentType: string | undefined, filename: string): string {
|
|
const ct = (contentType || "").toLowerCase();
|
|
const fn = (filename || "").toLowerCase();
|
|
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
|
|
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp)$/.test(fn)) return "image";
|
|
if (
|
|
ct.includes("officedocument") ||
|
|
ct.includes("msword") ||
|
|
ct.includes("ms-excel") ||
|
|
/\.(docx?|xlsx?)$/.test(fn)
|
|
) {
|
|
return "doc";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
async function countQuoteItems(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
quoteId: string
|
|
): Promise<number> {
|
|
const list = await pb.collection(QUOTE_ITEMS_COLLECTION).getList(1, 1, {
|
|
filter: `quote = "${quoteId}"`,
|
|
});
|
|
return list.totalItems;
|
|
}
|
|
|
|
async function countQuoteAttachments(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
quoteId: string
|
|
): Promise<number> {
|
|
const list = await pb.collection(QUOTE_ATTACHMENTS_COLLECTION).getList(1, 1, {
|
|
filter: `quote = "${quoteId}"`,
|
|
});
|
|
return list.totalItems;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Reply handling
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Appends a reply to an existing quote. Match priority:
|
|
* 1. `In-Reply-To` header → look up `quotes.email_message_id`
|
|
* 2. `Q-YYYY-MM-NNN` extracted from subject → look up `quotes.quote_number`
|
|
* 3. Otherwise returns `{ matched_by: 'none' }` so the caller can move the
|
|
* email to the Unmatched folder.
|
|
*
|
|
* The reply is stored as a `quote_replies` row. We never mutate
|
|
* `quotes.notes` — the human-readable thread is joined at render time.
|
|
*/
|
|
export async function appendReplyToQuote(
|
|
payload: EmailReplyPayload
|
|
): Promise<AppendReplyResult> {
|
|
const pb = await getAdminPocketBase();
|
|
const receivedAt = payload.received_at ? new Date(payload.received_at) : new Date();
|
|
|
|
const { quote, matched_by } = await resolveReplyTarget(pb, payload);
|
|
|
|
if (!quote) {
|
|
return { quote: null, reply: null, matched_by: "none" };
|
|
}
|
|
|
|
const reply = await pb.collection(QUOTE_REPLIES_COLLECTION).create({
|
|
quote: quote.id,
|
|
from_email: payload.from_email,
|
|
from_name: payload.from_name || "",
|
|
subject: payload.subject,
|
|
body_plain: payload.body_plain || "",
|
|
body_html: payload.body_html || "",
|
|
received_at: receivedAt.toISOString(),
|
|
email_message_id: payload.message_id || "",
|
|
in_reply_to: payload.in_reply_to || "",
|
|
quoted_text: payload.quoted_text || "",
|
|
});
|
|
|
|
await pb.collection(QUOTES_COLLECTION).update(quote.id, {
|
|
has_new_reply: true,
|
|
last_reply_at: receivedAt.toISOString(),
|
|
});
|
|
|
|
return { quote: quote as Quote, reply, matched_by };
|
|
}
|
|
|
|
async function resolveReplyTarget(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
payload: EmailReplyPayload
|
|
): Promise<{ quote: Quote | null; matched_by: AppendReplyResult["matched_by"] }> {
|
|
if (payload.in_reply_to) {
|
|
const q = await findQuoteByMessageId(pb, payload.in_reply_to);
|
|
if (q) return { quote: q, matched_by: "in_reply_to" };
|
|
}
|
|
const quoteNumber = extractQuoteNumberFromSubject(payload.subject);
|
|
if (quoteNumber) {
|
|
try {
|
|
const q = await pb
|
|
.collection(QUOTES_COLLECTION)
|
|
.getFirstListItem(`quote_number = "${quoteNumber}"`);
|
|
return { quote: q as Quote, matched_by: "subject" };
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return { quote: null, matched_by: "none" };
|
|
}
|
|
|
|
export function extractQuoteNumberFromSubject(subject: string): string | null {
|
|
const m = (subject || "").match(QUOTE_NUMBER_REGEX);
|
|
if (!m) return null;
|
|
return `Q-${m[1]}-${m[2]}-${m[3]}`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Lookups
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Looks up a quote by the inbound email's `Message-ID` header. Returns null
|
|
* if no quote matches. Indexed by `quotes.email_message_id`.
|
|
*/
|
|
export async function findQuoteByMessageId(
|
|
pbArg?: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
messageId?: string
|
|
): Promise<Quote | null>;
|
|
export async function findQuoteByMessageId(messageId: string): Promise<Quote | null>;
|
|
export async function findQuoteByMessageId(
|
|
pbOrMessageId: Awaited<ReturnType<typeof getAdminPocketBase>> | string,
|
|
maybeMessageId?: string
|
|
): Promise<Quote | null> {
|
|
let pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
|
|
let messageId: string;
|
|
if (typeof pbOrMessageId === "string") {
|
|
pb = await getAdminPocketBase();
|
|
messageId = pbOrMessageId;
|
|
} else {
|
|
pb = pbOrMessageId;
|
|
messageId = maybeMessageId || "";
|
|
}
|
|
if (!messageId) return null;
|
|
const esc = messageId.replace(/"/g, '\\"');
|
|
try {
|
|
const hit = await pb
|
|
.collection(QUOTES_COLLECTION)
|
|
.getFirstListItem(`email_message_id = "${esc}"`);
|
|
return hit as Quote;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Message-ID synthesis (fallback when the inbound email has no header)
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Builds a stable, deterministic Message-ID for an email that arrived without
|
|
* one. The hash is over `from_email + subject + received_at` so the same
|
|
* email processed twice dedups to the same id. The `synth-` prefix and
|
|
* `@wfdash.local` suffix make it visually obvious in PocketBase that this
|
|
* isn't a real RFC 5322 Message-ID.
|
|
*
|
|
* Collisions are not a correctness concern: two real emails with identical
|
|
* sender + subject + received_at are the same email. The hash is FNV-1a 32-bit
|
|
* which is overkill for this input space but cheap and stable across runs.
|
|
*/
|
|
export function synthesizeMessageId(payload: {
|
|
from_email: string;
|
|
subject: string;
|
|
received_at?: string;
|
|
}): string {
|
|
const input = `${payload.from_email}|${payload.subject}|${payload.received_at ?? ""}`;
|
|
let hash = 0x811c9dc5;
|
|
for (let i = 0; i < input.length; i++) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = Math.imul(hash, 0x01000193);
|
|
}
|
|
return `synth-${(hash >>> 0).toString(16).padStart(8, "0")}@wfdash.local`;
|
|
}
|