Pre-Final Backup
This commit is contained in:
280
lib/services/email.service.ts
Normal file
280
lib/services/email.service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user