513 lines
20 KiB
TypeScript
513 lines
20 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;
|
|
}
|
|
|
|
interface ContractDeadlineEmailInput {
|
|
to: string;
|
|
userDisplayName?: string | null;
|
|
contractId: string;
|
|
contractTitle: string | null;
|
|
contractProvider: string | null;
|
|
contractEndDate: Date;
|
|
daysUntilExpiration: number;
|
|
}
|
|
|
|
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}`;
|
|
};
|
|
|
|
const getBaseUrl = (): string | null => {
|
|
const baseUrl =
|
|
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
|
|
return baseUrl ? baseUrl.replace(/\/$/, "") : null;
|
|
};
|
|
|
|
const getLogoUrl = (): string | null => {
|
|
const baseUrl = getBaseUrl();
|
|
return baseUrl ? `${baseUrl}/LexiChain.png` : null;
|
|
};
|
|
|
|
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 logoUrl = getLogoUrl();
|
|
const blockchainStatus = input.blockchain
|
|
? "Registered"
|
|
: "Not registered (blockchain unavailable or skipped)";
|
|
|
|
const textBody = [
|
|
`Hello ${recipientName},`,
|
|
"",
|
|
"Your LexiChain contract intelligence report is ready.",
|
|
"",
|
|
"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}`,
|
|
"",
|
|
"Executive 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}` : "",
|
|
"",
|
|
"Thank you for trusting LexiChain for secure contract governance.",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
const htmlBody = `
|
|
<div style="margin:0;padding:0;background:#0f172a;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
|
|
<tr>
|
|
<td style="padding:24px 28px;background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 100%);color:#ffffff;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
|
<tr>
|
|
<td align="left" style="vertical-align:middle;">
|
|
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="140" style="display:block;border-radius:10px;" />` : '<strong style="font-size:20px;">LexiChain</strong>'}
|
|
</td>
|
|
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
|
|
Contract Intelligence
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<h1 style="margin:20px 0 6px;font-size:26px;">Your contract insight report is ready</h1>
|
|
<p style="margin:0;font-size:14px;opacity:0.9;">Clear, verified, and ready for action.</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:24px 28px;">
|
|
<p style="margin:0 0 16px;font-size:14px;">Hello ${recipientName},</p>
|
|
<p style="margin:0 0 20px;font-size:14px;color:#334155;">
|
|
Your LexiChain analysis is complete. Below is the executive blueprint and proof trace.
|
|
</p>
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
|
|
<tr>
|
|
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Blueprint Summary</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:13px;color:#475569;">
|
|
<div><strong>Contract:</strong> ${input.contractTitle}</div>
|
|
<div><strong>File:</strong> ${input.contractFileName}</div>
|
|
<div><strong>Type:</strong> ${input.blueprint.type}</div>
|
|
<div><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</div>
|
|
<div><strong>Policy #:</strong> ${input.blueprint.policyNumber ?? "N/A"}</div>
|
|
<div><strong>Start:</strong> ${formatDateValue(input.blueprint.startDate)}</div>
|
|
<div><strong>End:</strong> ${formatDateValue(input.blueprint.endDate)}</div>
|
|
<div><strong>Premium:</strong> ${premiumLabel}</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div style="margin:18px 0 8px;font-size:13px;font-weight:bold;">Executive Summary</div>
|
|
<p style="margin:0 0 18px;font-size:13px;color:#475569;line-height:1.6;">${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:14px;padding:16px;">
|
|
<tr>
|
|
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;color:#1e3a8a;">Blockchain Proof</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:12px;color:#1e3a8a;line-height:1.6;">
|
|
<div><strong>Status:</strong> ${blockchainStatus}</div>
|
|
<div><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</div>
|
|
<div><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</div>
|
|
<div><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</div>
|
|
<div><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</div>
|
|
<div><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</div>
|
|
<div><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</div>
|
|
<div><strong>Explorer:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;text-decoration:none;">Open transaction</a>` : "N/A"}</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
${
|
|
contractUrl
|
|
? `
|
|
<div style="margin-top:22px;">
|
|
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Open in LexiChain</a>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;">Precision you can audit. Trust you can prove.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
const info = await mailer.sendMail({
|
|
from,
|
|
to: input.to,
|
|
subject: `Contract analyzed: ${input.contractTitle}`,
|
|
html: htmlBody,
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
},
|
|
});
|
|
|
|
const previewUrl =
|
|
(nodemailer.getTestMessageUrl(info) as string | false) || null;
|
|
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",
|
|
};
|
|
}
|
|
}
|
|
|
|
static async sendContractDeadlineReminderEmail(
|
|
input: ContractDeadlineEmailInput,
|
|
): 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 contractUrl = formatContractLink(input.contractId);
|
|
const logoUrl = getLogoUrl();
|
|
const endDate = input.contractEndDate.toLocaleDateString();
|
|
|
|
const urgencyLabel =
|
|
input.daysUntilExpiration <= 7
|
|
? "Urgent"
|
|
: input.daysUntilExpiration <= 14
|
|
? "High"
|
|
: "Planned";
|
|
|
|
const textBody = [
|
|
`Hello ${recipientName},`,
|
|
"",
|
|
`Your contract deadline is approaching in ${input.daysUntilExpiration} days.`,
|
|
"",
|
|
`Contract: ${input.contractTitle ?? "Untitled contract"}`,
|
|
`Provider: ${input.contractProvider ?? "N/A"}`,
|
|
`End date: ${endDate}`,
|
|
`Urgency: ${urgencyLabel}`,
|
|
"",
|
|
contractUrl ? `Open in app: ${contractUrl}` : "",
|
|
"",
|
|
"Please review the contract and schedule renewal if needed.",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
const htmlBody = `
|
|
<div style="margin:0;padding:0;background:#0f172a;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
|
|
<tr>
|
|
<td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a 0%,#2563eb 100%);color:#ffffff;">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
|
<tr>
|
|
<td align="left" style="vertical-align:middle;">
|
|
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="130" style="display:block;border-radius:10px;" />` : '<strong style="font-size:18px;">LexiChain</strong>'}
|
|
</td>
|
|
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
|
|
Deadline Alert
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<h1 style="margin:18px 0 6px;font-size:24px;">Contract renewal reminder</h1>
|
|
<p style="margin:0;font-size:14px;opacity:0.9;">Stay ahead of critical dates.</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<tr>
|
|
<td style="padding:24px 28px;">
|
|
<p style="margin:0 0 14px;font-size:14px;">Hello ${recipientName},</p>
|
|
<p style="margin:0 0 18px;font-size:14px;color:#334155;">
|
|
Your contract deadline is approaching in <strong>${input.daysUntilExpiration} days</strong>.
|
|
</p>
|
|
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
|
|
<tr>
|
|
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Deadline Summary</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:13px;color:#475569;line-height:1.6;">
|
|
<div><strong>Contract:</strong> ${input.contractTitle ?? "Untitled contract"}</div>
|
|
<div><strong>Provider:</strong> ${input.contractProvider ?? "N/A"}</div>
|
|
<div><strong>End date:</strong> ${endDate}</div>
|
|
<div><strong>Urgency:</strong> ${urgencyLabel}</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
${
|
|
contractUrl
|
|
? `
|
|
<div style="margin-top:20px;">
|
|
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Review in LexiChain</a>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
<p style="margin:18px 0 0;font-size:12px;color:#94a3b8;">Plan renewals early to avoid coverage gaps.</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
const info = await mailer.sendMail({
|
|
from,
|
|
to: input.to,
|
|
subject: `Contract deadline in ${input.daysUntilExpiration} days`,
|
|
html: htmlBody,
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
},
|
|
});
|
|
|
|
const previewUrl =
|
|
(nodemailer.getTestMessageUrl(info) as string | false) || null;
|
|
if (previewUrl) {
|
|
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
|
|
}
|
|
|
|
return { success: true, previewUrl };
|
|
} catch (error) {
|
|
console.error("Failed to send deadline reminder email:", error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown email error",
|
|
};
|
|
}
|
|
}
|
|
}
|