281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
import nodemailer from "nodemailer";
|
|
|
|
interface ContractBlueprint {
|
|
type: string;
|
|
provider: string | null;
|
|
policyNumber: string | null;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
premium: number | null;
|
|
premiumCurrency: string | null;
|
|
summary: string;
|
|
}
|
|
|
|
interface BlockchainEmailData {
|
|
documentHash: string;
|
|
txHash: string;
|
|
blockNumber: number;
|
|
blockTimestamp: Date;
|
|
network: string;
|
|
contractAddress: string;
|
|
explorerUrl: string | null;
|
|
}
|
|
|
|
interface ContractAnalysisEmailInput {
|
|
to: string;
|
|
userDisplayName?: string | null;
|
|
contractId: string;
|
|
contractFileName: string;
|
|
contractTitle: string;
|
|
blueprint: ContractBlueprint;
|
|
blockchain?: BlockchainEmailData | null;
|
|
}
|
|
|
|
let transporter: nodemailer.Transporter | null = null;
|
|
let transportMode: "smtp" | "ethereal" | null = null;
|
|
let hasWarnedMissingEmailConfig = false;
|
|
|
|
const asBoolean = (value: string | undefined, fallback: boolean): boolean => {
|
|
if (!value) return fallback;
|
|
return value.toLowerCase() === "true" || value === "1";
|
|
};
|
|
|
|
const isEmailConfigured = (): boolean => {
|
|
return Boolean(
|
|
process.env.EMAIL_HOST &&
|
|
process.env.EMAIL_PORT &&
|
|
process.env.EMAIL_USER &&
|
|
process.env.EMAIL_PASS,
|
|
);
|
|
};
|
|
|
|
const warnMissingEmailConfigOnce = () => {
|
|
if (hasWarnedMissingEmailConfig) return;
|
|
hasWarnedMissingEmailConfig = true;
|
|
console.warn(
|
|
"Email notifications are disabled. Configure EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, and MAIL_FROM to enable contract summary emails.",
|
|
);
|
|
};
|
|
|
|
const getTransporter = async (): Promise<nodemailer.Transporter | null> => {
|
|
if (transporter) {
|
|
return transporter;
|
|
}
|
|
|
|
if (isEmailConfigured()) {
|
|
transportMode = "smtp";
|
|
transporter = nodemailer.createTransport({
|
|
host: process.env.EMAIL_HOST,
|
|
port: Number(process.env.EMAIL_PORT),
|
|
secure: asBoolean(
|
|
process.env.EMAIL_SECURE,
|
|
Number(process.env.EMAIL_PORT) === 465,
|
|
),
|
|
auth: {
|
|
user: process.env.EMAIL_USER,
|
|
pass: process.env.EMAIL_PASS,
|
|
},
|
|
});
|
|
|
|
return transporter;
|
|
}
|
|
|
|
if (process.env.NODE_ENV !== "production") {
|
|
const testAccount = await nodemailer.createTestAccount();
|
|
transportMode = "ethereal";
|
|
transporter = nodemailer.createTransport({
|
|
host: testAccount.smtp.host,
|
|
port: testAccount.smtp.port,
|
|
secure: testAccount.smtp.secure,
|
|
auth: {
|
|
user: testAccount.user,
|
|
pass: testAccount.pass,
|
|
},
|
|
});
|
|
|
|
console.warn(
|
|
"Email service is running in development fallback mode using Ethereal. Configure SMTP env vars for real inbox delivery.",
|
|
);
|
|
|
|
return transporter;
|
|
}
|
|
|
|
warnMissingEmailConfigOnce();
|
|
return null;
|
|
};
|
|
|
|
const formatPremium = (
|
|
premium: number | null,
|
|
currency: string | null,
|
|
): string => {
|
|
if (premium === null || premium === undefined) return "N/A";
|
|
const formattedAmount = new Intl.NumberFormat("en-US", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(premium);
|
|
|
|
if (!currency) return formattedAmount;
|
|
if (["€", "$", "£"].includes(currency))
|
|
return `${currency}${formattedAmount}`;
|
|
return `${formattedAmount} ${currency}`;
|
|
};
|
|
|
|
const formatDateValue = (dateValue: string | null): string => {
|
|
if (!dateValue) return "N/A";
|
|
const date = new Date(dateValue);
|
|
if (Number.isNaN(date.getTime())) return dateValue;
|
|
return date.toISOString().split("T")[0];
|
|
};
|
|
|
|
const formatContractLink = (contractId: string): string | null => {
|
|
const baseUrl =
|
|
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
|
|
if (!baseUrl) return null;
|
|
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
|
|
};
|
|
|
|
export class EmailService {
|
|
static async sendContractAnalysisCompletedEmail(
|
|
input: ContractAnalysisEmailInput,
|
|
): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
skipped?: boolean;
|
|
previewUrl?: string | null;
|
|
}> {
|
|
try {
|
|
const mailer = await getTransporter();
|
|
if (!mailer) {
|
|
return {
|
|
success: false,
|
|
skipped: true,
|
|
error: "Email service not configured",
|
|
};
|
|
}
|
|
|
|
const from =
|
|
process.env.MAIL_FROM?.trim() ||
|
|
process.env.EMAIL_USER?.trim() ||
|
|
(transportMode === "ethereal"
|
|
? "LexiChain <no-reply@ethereal.email>"
|
|
: "");
|
|
if (!from) {
|
|
warnMissingEmailConfigOnce();
|
|
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
|
|
}
|
|
|
|
if (!input.to?.trim()) {
|
|
return {
|
|
success: false,
|
|
skipped: true,
|
|
error: "Recipient email is missing",
|
|
};
|
|
}
|
|
|
|
const recipientName = input.userDisplayName || "there";
|
|
const premiumLabel = formatPremium(
|
|
input.blueprint.premium,
|
|
input.blueprint.premiumCurrency,
|
|
);
|
|
const contractUrl = formatContractLink(input.contractId);
|
|
const blockchainStatus = input.blockchain
|
|
? "Registered"
|
|
: "Not registered (blockchain unavailable or skipped)";
|
|
|
|
const textBody = [
|
|
`Hello ${recipientName},`,
|
|
"",
|
|
"Your contract analysis is complete.",
|
|
"",
|
|
"Blueprint:",
|
|
`- Contract title: ${input.contractTitle}`,
|
|
`- Original file: ${input.contractFileName}`,
|
|
`- Type: ${input.blueprint.type}`,
|
|
`- Provider: ${input.blueprint.provider ?? "N/A"}`,
|
|
`- Policy number: ${input.blueprint.policyNumber ?? "N/A"}`,
|
|
`- Start date: ${formatDateValue(input.blueprint.startDate)}`,
|
|
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
|
|
`- Premium: ${premiumLabel}`,
|
|
"",
|
|
"Summary:",
|
|
input.blueprint.summary,
|
|
"",
|
|
"Blockchain proof:",
|
|
`- Status: ${blockchainStatus}`,
|
|
`- Document hash: ${input.blockchain?.documentHash ?? "N/A"}`,
|
|
`- Transaction hash: ${input.blockchain?.txHash ?? "N/A"}`,
|
|
`- Block number: ${input.blockchain?.blockNumber ?? "N/A"}`,
|
|
`- Block time: ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}`,
|
|
`- Network: ${input.blockchain?.network ?? "N/A"}`,
|
|
`- Contract address: ${input.blockchain?.contractAddress ?? "N/A"}`,
|
|
`- Explorer URL: ${input.blockchain?.explorerUrl ?? "N/A"}`,
|
|
"",
|
|
contractUrl ? `Open in app: ${contractUrl}` : "",
|
|
"",
|
|
"Keep this email for your records.",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
const htmlBody = `
|
|
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
|
|
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
|
|
<p>Hello ${recipientName},</p>
|
|
<p>Your contract analysis has been completed successfully.</p>
|
|
|
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
|
|
<ul>
|
|
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
|
|
<li><strong>Original file:</strong> ${input.contractFileName}</li>
|
|
<li><strong>Type:</strong> ${input.blueprint.type}</li>
|
|
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
|
|
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
|
|
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
|
|
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
|
|
<li><strong>Premium:</strong> ${premiumLabel}</li>
|
|
</ul>
|
|
|
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
|
|
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
|
|
|
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
|
|
<ul>
|
|
<li><strong>Status:</strong> ${blockchainStatus}</li>
|
|
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
|
|
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
|
|
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
|
|
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
|
|
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
|
|
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
|
|
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
|
|
</ul>
|
|
|
|
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
|
|
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p>
|
|
</div>
|
|
`;
|
|
|
|
const info = await mailer.sendMail({
|
|
from,
|
|
to: input.to,
|
|
subject: `Contract analyzed: ${input.contractTitle}`,
|
|
text: textBody,
|
|
html: htmlBody,
|
|
});
|
|
|
|
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
if (previewUrl) {
|
|
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
|
|
}
|
|
|
|
return { success: true, previewUrl };
|
|
} catch (error) {
|
|
console.error("Failed to send analysis completion email:", error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown email error",
|
|
};
|
|
}
|
|
}
|
|
}
|