commit 509cb17e0978d97b7e46f2364d888c4e79c0b4c0
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Tue, 20 Oct 2026 17:05:34 +0530
Initialize email router with Express and Firebase
Diffstat:
| A | server.js | | | 470 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 470 insertions(+), 0 deletions(-)
diff --git a/server.js b/server.js
@@ -0,0 +1,470 @@
+const express = require('express');
+const { Resend } = require('resend');
+const { Webhook } = require('svix');
+const { simpleParser } = require('mailparser');
+const admin = require('firebase-admin');
+
+const app = express();
+const port = process.env.PORT || 3000;
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+const webhookSecret = process.env.RESEND_WEBHOOK_SECRET;
+
+const escapeHtml = (value) =>
+ String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+// Pull a display name out of `"Jane Doe" <jane@example.com>`; fall back to the address.
+// The result is stripped of characters that could break a From header.
+const displayNameOf = (from) => {
+ const match = String(from || '').match(/^\s*(?:"([^"]*)"|([^<]+?))\s*<([^>]+)>\s*$/);
+ const name = match ? (match[1] || match[2] || match[3]) : String(from || 'Unknown sender');
+ return name.replace(/[<>"\\\r\n]/g, '').trim() || 'Unknown sender';
+};
+
+// Bare address from `"Jane" <jane@example.com>` or `jane@example.com`.
+const addressOf = (value) => {
+ const match = String(value || '').match(/<([^>]+)>/);
+ return (match ? match[1] : String(value || '')).trim();
+};
+
+const DISPLAY_TZ = process.env.DISPLAY_TZ || 'Asia/Kolkata';
+const formatReceived = (date) =>
+ new Intl.DateTimeFormat('en-GB', {
+ timeZone: DISPLAY_TZ,
+ weekday: 'short', day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit', hour12: true, timeZoneName: 'short',
+ }).format(date).replace('GMT+5:30', 'IST');
+
+// Info banner shown above the original message: who sent it, where it arrived,
+// when, a trace link into Resend, and a Reply button. Inline styles only, so it
+// survives email clients.
+const buildBanner = ({ from, to, receivedAt, emailId, subject, replyTarget, attachmentCount }) => {
+ const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject}`;
+ const replyHref = `mailto:${encodeURI(addressOf(replyTarget))}?subject=${encodeURIComponent(replySubject)}`;
+ const traceHref = `https://resend.com/emails/${encodeURIComponent(emailId)}`;
+
+ const label = 'width: 84px; padding: 6px 0; vertical-align: top; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #5f6368;';
+ const value = 'padding: 6px 0; vertical-align: top; font-size: 14px; line-height: 1.4; color: #1f1f1f; word-break: break-word;';
+
+ const row = (name, content) =>
+ `<tr><td style="${label}">${name}</td><td style="${value}">${content}</td></tr>`;
+
+ const attachmentRow = attachmentCount > 0
+ ? row('Files', `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'} (included below)`)
+ : '';
+
+ return `<div style="max-width: 720px; margin: 0 0 20px 0; font-family: Roboto, Arial, Helvetica, sans-serif;">`
+ + `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border: 1px solid #dadce0; border-radius: 12px; border-collapse: separate; background-color: #f6f8fc;">`
+ + `<tr><td style="padding: 16px 20px 4px 20px;">`
+ + `<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #0b57d0; margin-bottom: 8px;">Incoming mail</div>`
+ + `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">`
+ + row('From', `<b>${escapeHtml(displayNameOf(from))}</b> <${escapeHtml(addressOf(from))}>`)
+ + row('To', escapeHtml(to))
+ + row('Received', escapeHtml(formatReceived(receivedAt)))
+ + row('Trace ID', `<a href="${escapeHtml(traceHref)}" target="_blank" style="font-family: 'Roboto Mono', Consolas, monospace; font-size: 12px; color: #0b57d0; text-decoration: none;">${escapeHtml(emailId)}</a>`)
+ + attachmentRow
+ + `</table></td></tr>`
+ + `<tr><td style="padding: 8px 20px 18px 20px;">`
+ + `<a href="${escapeHtml(replyHref)}" style="display: inline-block; padding: 9px 22px; margin-right: 8px; background-color: #0b57d0; color: #ffffff; font-size: 14px; font-weight: 500; text-decoration: none; border-radius: 100px;">Reply</a>`
+ + `<a href="${escapeHtml(traceHref)}" target="_blank" style="display: inline-block; padding: 8px 20px; border: 1px solid #0b57d0; color: #0b57d0; font-size: 14px; font-weight: 500; text-decoration: none; border-radius: 100px;">Trace in Resend</a>`
+ + `</td></tr></table></div>`;
+};
+
+// Put the banner just inside the original message's <body>, so the result is
+// still one valid HTML document. Messages without a <body> get it prepended.
+const injectBanner = (html, banner) => {
+ const bodyOpen = /<body[^>]*>/i;
+ return bodyOpen.test(html) ? html.replace(bodyOpen, (tag) => tag + banner) : banner + html;
+};
+
+// ---- Firestore archive (optional) -------------------------------------------
+// Set FIREBASE_SERVICE_ACCOUNT to the service-account JSON (raw or base64).
+// Without it the server still forwards mail; it just skips the archive.
+let db = null;
+try {
+ const rawAccount = process.env.FIREBASE_SERVICE_ACCOUNT;
+ if (rawAccount) {
+ const json = rawAccount.trim().startsWith('{')
+ ? rawAccount
+ : Buffer.from(rawAccount, 'base64').toString('utf8');
+ admin.initializeApp({ credential: admin.credential.cert(JSON.parse(json)) });
+ db = admin.firestore();
+ } else {
+ console.warn('FIREBASE_SERVICE_ACCOUNT not set - mail will not be archived');
+ }
+} catch (err) {
+ console.error('Firebase init failed - mail will not be archived:', err.message);
+}
+
+// Firestore documents are capped at ~1 MiB, so keep each body field well under it.
+const MAX_BODY_CHARS = 250000;
+const capBody = (value) => (value ? value.slice(0, MAX_BODY_CHARS) : null);
+
+// Save one received mail, keyed by Resend's email ID. create() fails if the
+// document exists, so a webhook retry never duplicates it or resets `read`.
+const archiveEmail = async ({ emailId, from, to, subject, receivedAt, parsed, fetchError }) => {
+ if (!db) return;
+ try {
+ await db.collection('mails').doc(emailId).create({
+ from,
+ to,
+ subject,
+ html: capBody(parsed?.html),
+ text: capBody(parsed?.text),
+ truncated: (parsed?.html?.length || 0) > MAX_BODY_CHARS || (parsed?.text?.length || 0) > MAX_BODY_CHARS,
+ receivedAt: admin.firestore.Timestamp.fromDate(receivedAt),
+ resendEmailId: emailId,
+ read: false,
+ messageId: parsed?.messageId || null,
+ inReplyTo: parsed?.inReplyTo || null,
+ references: parsed?.references ? [].concat(parsed.references) : [],
+ attachments: (parsed?.attachments || []).map((a) => ({
+ filename: a.filename || 'attachment',
+ contentType: a.contentType || null,
+ size: a.size || 0,
+ })),
+ fetchError: fetchError || null,
+ });
+ } catch (err) {
+ if (err.code === 6 || /already exists/i.test(err.message)) return; // retry of a mail we already saved
+ throw err;
+ }
+};
+
+// Resend's email.received webhook only carries metadata (no body, headers or
+// attachments). Fetch the message itself via the Received Emails API.
+const fetchFullEmail = async (emailId) => {
+ const { data: received, error } = await resend.emails.receiving.get(emailId);
+ if (error) throw new Error(`Could not retrieve received email: ${error.message}`);
+
+ const downloadUrl = received?.raw?.download_url;
+ if (!downloadUrl) throw new Error('Received email has no raw download URL');
+
+ const rawResponse = await fetch(downloadUrl);
+ if (!rawResponse.ok) throw new Error(`Raw email download failed (${rawResponse.status})`);
+
+ const raw = Buffer.from(await rawResponse.arrayBuffer());
+ return simpleParser(raw, { skipImageLinks: true });
+};
+
+app.post('/api/incoming', express.text({ type: 'application/json' }), async (req, res) => {
+ try {
+ const rawBody = req.body;
+ const headers = {
+ 'svix-id': req.headers['svix-id'],
+ 'svix-timestamp': req.headers['svix-timestamp'],
+ 'svix-signature': req.headers['svix-signature'],
+ };
+
+ const wh = new Webhook(webhookSecret);
+ let event;
+
+ try {
+ event = wh.verify(rawBody, headers);
+ } catch (err) {
+ console.error(err.message);
+ return res.status(400).json({ error: 'Invalid signature' });
+ }
+
+ if (event.type !== 'email.received') {
+ return res.status(200).json({ message: 'Not an email event' });
+ }
+
+ const emailData = event.data;
+ const originalSender = emailData.from;
+ const originalRecipient = emailData.to[0].toLowerCase();
+ const subject = emailData.subject || 'No Subject';
+
+ const allowedString = process.env.ALLOWED_ALIASES || '';
+ const allowedAliases = allowedString.split(',').map(alias => alias.trim().toLowerCase());
+
+ if (!allowedAliases.includes(originalRecipient)) {
+ return res.status(200).json({ success: true, message: 'Alias ignored' });
+ }
+
+ const personalGmail = process.env.PERSONAL_GMAIL;
+ const forwardingAddress = process.env.FORWARDING_BOT_ADDRESS;
+
+ // Premium Adaptive Google-style HTML Template.
+ // Only used as a fallback now, if the full message cannot be fetched.
+ const buildNotificationHtml = () => `
+<!DOCTYPE html>
+<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="color-scheme" content="light dark">
+ <meta name="supported-color-schemes" content="light dark">
+ <title>New Email Notification</title>
+
+ <style>
+ @import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Roboto+Mono:wght@400;500&family=Roboto:wght@400;500&display=swap');
+
+ body {
+ margin: 0;
+ padding: 0;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ .btn-pill:hover {
+ opacity: 0.9;
+ transform: translateY(-1px);
+ transition: all 0.2s ease;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ .bg-outer { background-color: #131314 !important; }
+ .bg-card { background-color: #1E1F20 !important; }
+ .bg-tint { background-color: #282A2C !important; }
+ .text-primary { color: #E3E3E3 !important; }
+ .text-secondary { color: #C4C7C5 !important; }
+ .text-accent { color: #A8C7FA !important; }
+ .border-card { border-color: #444746 !important; }
+ .divider { background-color: #444746 !important; }
+ .btn-pill {
+ background-color: #A8C7FA !important;
+ color: #000000 !important;
+ }
+ .icon-fill { fill: #8E918F !important; }
+ }
+
+ /* Gmail Dark Mode Fixes */
+ [data-ogsb] .bg-outer { background-color: #131314 !important; }
+ [data-ogsb] .bg-card { background-color: #1E1F20 !important; }
+ [data-ogsb] .bg-tint { background-color: #282A2C !important; }
+ [data-ogsc] .text-primary { color: #E3E3E3 !important; }
+ [data-ogsc] .text-secondary { color: #C4C7C5 !important; }
+ [data-ogsc] .text-accent { color: #A8C7FA !important; }
+ [data-ogsb] .border-card { border-color: #444746 !important; }
+ [data-ogsb] .divider { background-color: #444746 !important; }
+ [data-ogsb] .btn-pill { background-color: #A8C7FA !important; }
+ [data-ogsc] .btn-pill { color: #000000 !important; }
+ [data-ogsc] .icon-fill { fill: #8E918F !important; }
+ </style>
+</head>
+
+<body class="bg-outer" style="margin: 0; padding: 0; background-color: #F8F9FA; font-family: 'Google Sans', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased;">
+
+ <table width="100%" border="0" cellspacing="0" cellpadding="0" class="bg-outer" style="background-color: #F8F9FA; width: 100%; height: 100%;">
+ <tr>
+ <td align="center" style="padding: 40px 16px;">
+
+ <table border="0" cellspacing="0" cellpadding="0" class="bg-card border-card" style="background-color: #FFFFFF; border-radius: 28px; max-width: 600px; width: 100%; overflow: hidden; border: 1px solid #C4C7C5;">
+
+ <tr>
+ <td style="padding: 40px 32px 16px 32px;">
+ <h2 class="text-primary" style="margin: 0; font-family: 'Google Sans', Roboto, sans-serif; font-size: 22px; font-weight: 500; color: #1F1F1F; letter-spacing: -0.5px;">Incoming Message</h2>
+ <p class="text-secondary" style="margin: 8px 0 0 0; font-family: Roboto, Arial, sans-serif; font-size: 14px; font-weight: 400; color: #444746; line-height: 1.5;">A new email has been routed to your developer inbox.</p>
+ </td>
+ </tr>
+
+ <tr>
+ <td style="padding: 0 32px;">
+ <div class="divider" style="height: 1px; background-color: #E3E3E3; width: 100%; margin: 16px 0;"></div>
+ </td>
+ </tr>
+
+ <tr>
+ <td style="padding: 16px 32px;">
+
+ <div style="margin-bottom: 24px;">
+ <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Sender Email ID</span>
+ <span class="text-primary" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 16px; font-weight: 400; color: #1F1F1F; word-break: break-all; line-height: 1.4;">${escapeHtml(originalSender)}</span>
+ </div>
+
+ <div style="margin-bottom: 24px;">
+ <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Subject</span>
+ <span class="text-primary" style="display: block; font-family: 'Google Sans', Roboto, Arial, sans-serif; font-size: 18px; font-weight: 500; color: #1F1F1F; line-height: 1.4;">${escapeHtml(subject)}</span>
+ </div>
+
+ <div style="margin-bottom: 32px;">
+ <span class="text-accent" style="display: block; font-family: Roboto, Arial, sans-serif; font-size: 11px; font-weight: 500; color: #0B57D0; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Email Tracking ID</span>
+
+ <table border="0" cellspacing="0" cellpadding="0" style="width: 100%;">
+ <tr>
+ <td class="bg-tint" style="background-color: #F0F4F9; border-radius: 8px; padding: 12px 16px;">
+ <span class="text-secondary" style="display: block; font-family: 'Roboto Mono', monospace; font-size: 14px; font-weight: 500; color: #444746; word-break: break-all;">
+ ${escapeHtml(emailData.email_id || 'N/A')}
+ </span>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ </td>
+ </tr>
+
+ <tr>
+ <td style="padding: 8px 32px 40px 32px;">
+ <table border="0" cellspacing="0" cellpadding="0">
+ <tr>
+ <td align="center" style="border-radius: 100px;">
+ <a href="https://resend.com/emails/${emailData.email_id}" target="_blank" class="btn-pill" style="display: inline-block; padding: 12px 32px; background-color: #0B57D0; font-family: 'Google Sans', Roboto, Arial, sans-serif; font-size: 14px; font-weight: 500; color: #FFFFFF; text-decoration: none; border-radius: 100px;">
+ Open Resend Dashboard
+ </a>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+
+ </table>
+
+ <table border="0" cellspacing="0" cellpadding="0" style="max-width: 600px; width: 100%; margin-top: 32px;">
+ <tr>
+ <td align="center" style="padding-bottom: 24px;">
+ <p class="text-secondary" style="margin: 0; font-family: Roboto, Arial, sans-serif; font-size: 12px; color: #747775; line-height: 1.6;">Automated alert by Notifier Bot.<br>Please do not reply to this unmonitored email.</p>
+ </td>
+ </tr>
+
+ <tr>
+ <td align="center" style="padding-bottom: 16px;">
+ <table border="0" cellspacing="0" cellpadding="0">
+ <tr>
+ <td style="padding: 0 12px;">
+ <a href="https://amit.is-a.dev" target="_blank" style="text-decoration: none;" title="Website">
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;">
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
+ </svg>
+ </a>
+ </td>
+ <td style="padding: 0 12px;">
+ <a href="https://github.com/notamitgamer" target="_blank" style="text-decoration: none;" title="GitHub">
+ <svg width="20" height="20" viewBox="0 0 16 16" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;">
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
+ </svg>
+ </a>
+ </td>
+ <td style="padding: 0 12px;">
+ <a href="mailto:amitdutta4255@gmail.com" style="text-decoration: none;" title="Email">
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="#747775" class="icon-fill" xmlns="http://www.w3.org/2000/svg" style="display: block; border: 0;">
+ <path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
+ </svg>
+ </a>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+
+ <tr>
+ <td align="center">
+ <p class="text-secondary" style="margin: 0; font-family: Roboto, Arial, sans-serif; font-size: 12px; color: #747775;">
+ © 2025-2026 Amit Dutta • <a href="https://amit.is-a.dev" class="text-secondary" style="color: #747775; text-decoration: none;">amit.is-a.dev</a>
+ </p>
+ </td>
+ </tr>
+ </table>
+
+ </td>
+ </tr>
+ </table>
+
+</body>
+</html>
+ `;
+
+ // Try to forward the real message. If anything about fetching or parsing it
+ // fails, fall back to the old "open the dashboard" notification so mail is
+ // never silently dropped.
+ let payload;
+ let parsed = null;
+ let fetchErrorMessage = null;
+ const created = new Date(emailData.created_at);
+ const receivedAt = Number.isNaN(created.getTime()) ? new Date() : created;
+ try {
+ parsed = await fetchFullEmail(emailData.email_id);
+
+ const replyTarget = parsed.replyTo?.text || originalSender;
+ const senderName = displayNameOf(originalSender);
+
+ const banner = buildBanner({
+ from: originalSender,
+ to: originalRecipient,
+ receivedAt,
+ emailId: emailData.email_id,
+ subject,
+ replyTarget,
+ attachmentCount: (parsed.attachments || []).length,
+ });
+
+ const bodyHtml = parsed.html
+ ? parsed.html
+ : `<pre style="font-family: Arial, sans-serif; white-space: pre-wrap;">${escapeHtml(parsed.text || '(empty message)')}</pre>`;
+
+ const attachments = (parsed.attachments || []).map((attachment) => ({
+ filename: attachment.filename || 'attachment',
+ content: attachment.content.toString('base64'),
+ contentType: attachment.contentType,
+ contentId: attachment.contentId?.replace(/^<|>$/g, '') || undefined,
+ }));
+
+ payload = {
+ from: `"${senderName} via ${originalRecipient}" <${forwardingAddress}>`,
+ to: [personalGmail],
+ replyTo: replyTarget, // resend v6 ignores snake_case `reply_to`
+ subject: subject,
+ html: injectBanner(bodyHtml, banner),
+ text: `Sent to ${originalRecipient} by ${originalSender}\n\n${parsed.text || ''}`,
+ attachments: attachments.length > 0 ? attachments : undefined,
+ };
+ } catch (fetchError) {
+ console.error('Falling back to notification card:', fetchError.message);
+ fetchErrorMessage = fetchError.message;
+ payload = {
+ from: `Notifier Bot <${forwardingAddress}>`,
+ to: [personalGmail],
+ replyTo: originalSender,
+ subject: subject,
+ html: buildNotificationHtml()
+ };
+ }
+
+ // Archive in Firestore. A Firebase problem must never stop the mail reaching Gmail.
+ try {
+ await archiveEmail({
+ emailId: emailData.email_id,
+ from: originalSender,
+ to: originalRecipient,
+ subject,
+ receivedAt,
+ parsed,
+ fetchError: fetchErrorMessage,
+ });
+ } catch (archiveError) {
+ console.error('Firestore archive failed:', archiveError.message);
+ }
+
+ const { data, error } = await resend.emails.send(payload);
+
+ if (error) {
+ console.error(error);
+ return res.status(500).json({ error: error.message });
+ }
+
+ return res.status(200).json({ success: true, id: data?.id });
+
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error: 'Internal Server Error' });
+ }
+});
+
+app.get('/ping', (req, res) => {
+ res.status(200).send('Server is awake!');
+});
+
+// `node server.js` (Render) starts the server; requiring this file for tests does not.
+if (require.main === module) {
+ app.listen(port, () => {
+ console.log(`Email router listening on port ${port}`);
+ });
+}
+
+module.exports = { app, buildBanner, injectBanner, archiveEmail };