Skip to content

Commit 2cc2530

Browse files
malek10xdevJ43fura
andauthored
feat: add weekly email report for passive mining (#2599)
* Create 20260130124036_passive_mining.sql * Update MinePanel.vue * feat: add weekly email report for passive mining * add: edge-function refactor email templates * add: update debug messages * fix: fix migration timestamp & remove duplicate * add: use sql function for getting passive_mining ids * fix: frontend linting & formatting --------- Co-authored-by: Jaafoura <mehdi.jaafourag@gmail.com>
1 parent 7907b77 commit 2cc2530

File tree

11 files changed

+726
-5
lines changed

11 files changed

+726
-5
lines changed

frontend/src/components/mining/stepper-panels/mine/MinePanel.vue

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@
7373
:total-emails="totalEmails"
7474
:is-loading-boxes="$leadminerStore.isLoadingBoxes"
7575
/>
76+
77+
<Dialog
78+
v-model:visible="autoExtractDialog"
79+
modal
80+
header="Continuous Contact Extraction"
81+
class="w-full sm:w-[35rem]"
82+
>
83+
<p>
84+
Enable continuous contact extraction from future emails?
85+
<br />
86+
New contacts found in incoming emails will be automatically saved.
87+
</p>
88+
<template #footer>
89+
<div class="flex flex-col sm:flex-row justify-between w-full gap-2">
90+
<Button
91+
label="Yes, enable"
92+
class="w-full sm:w-auto"
93+
@click="enableAutoExtract()"
94+
/>
95+
<Button
96+
:label="$t('common.cancel')"
97+
class="w-full sm:w-auto"
98+
severity="secondary"
99+
@click="closeAutoExtractDialog()"
100+
/>
101+
</div>
102+
</template>
103+
</Dialog>
76104
</template>
77105
<script setup lang="ts">
78106
// @ts-expect-error "No type definitions"
@@ -97,13 +125,21 @@ const { miningSource } = defineProps<{
97125
miningSource: MiningSource | undefined;
98126
}>();
99127
128+
const autoExtractDialog = ref(false); // mining_source.autoExtract
100129
const sourceType = computed(() => $leadminerStore.miningType);
101130
const $toast = useToast();
102131
const $stepper = useMiningStepper();
103132
const $leadminerStore = useLeadminerStore();
104133
const $contactsStore = useContactsStore();
105134
const $consentSidebar = useMiningConsentSidebar();
106135
136+
function closeAutoExtractDialog() {
137+
autoExtractDialog.value = false;
138+
}
139+
function enableAutoExtract() {
140+
closeAutoExtractDialog();
141+
}
142+
107143
const AVERAGE_EXTRACTION_RATE =
108144
parseInt(useRuntimeConfig().public.AVERAGE_EXTRACTION_RATE) || 130;
109145
const canceled = ref<boolean>(false);

supabase/functions/mail/deno.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"imports": {
33
"hono": "https://esm.sh/hono@4.7.4",
4-
"supabase": "https://esm.sh/@supabase/supabase-js@2"
4+
"supabase": "https://esm.sh/@supabase/supabase-js@2",
5+
"nodemailer": "npm:nodemailer@^7.0.5",
6+
"p-queue": "https://esm.sh/p-queue@9.1.0"
57
}
68
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
type EmailContainerArgs = {
2+
language: "en" | "fr";
3+
subject: string;
4+
headerTitle: string;
5+
headerSubtitle: string;
6+
body: string;
7+
footer: {
8+
line1: string;
9+
line2: string;
10+
brand: string;
11+
};
12+
};
13+
14+
export const LOGO_URL = Deno.env.get("LOGO_URL");
15+
export const FRONTEND_HOST = Deno.env.get("FRONTEND_HOST");
16+
export const LEADMINER_DATA_PRIVACY_URL = "https://www.leadminer.io/data-privacy";
17+
18+
/**
19+
* Simple template interpolator: replaces {var} with provided values
20+
*/
21+
export function t(
22+
template: string,
23+
vars: Record<string, string | number> = {},
24+
): string {
25+
return template.replace(/{(\w+)}/g, (_, key) => String(vars[key] ?? ""));
26+
}
27+
28+
29+
export function emailContainer({
30+
language,
31+
subject,
32+
headerTitle,
33+
headerSubtitle,
34+
body,
35+
footer,
36+
}: EmailContainerArgs): string {
37+
return `
38+
<!DOCTYPE html>
39+
<html lang="${language}">
40+
<head>
41+
<meta charset="UTF-8" />
42+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
43+
<title>${subject}</title>
44+
<link
45+
href="https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@400;600;700&family=Merriweather:wght@700&display=swap"
46+
rel="stylesheet"
47+
/>
48+
</head>
49+
<body
50+
style="
51+
margin: 0;
52+
background-color: #f9fafb;
53+
color: #111827;
54+
font-family: 'Lexend Deca', Arial, sans-serif;
55+
-webkit-font-smoothing: antialiased;
56+
"
57+
>
58+
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="padding: 40px 0">
59+
<tr>
60+
<td align="center">
61+
<table
62+
width="600"
63+
cellpadding="0"
64+
cellspacing="0"
65+
role="presentation"
66+
style="
67+
background: #ffffff;
68+
border-radius: 12px;
69+
overflow: hidden;
70+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
71+
"
72+
>
73+
<!-- Logo -->
74+
<tr>
75+
<td align="center" style="padding: 20px 0">
76+
<a href="${FRONTEND_HOST}" target="_blank" style="text-decoration: none">
77+
<img
78+
src="${LOGO_URL}"
79+
alt="Leadminer"
80+
border="0"
81+
style="display: block; height: 3.125rem; width: auto"
82+
/>
83+
</a>
84+
</td>
85+
</tr>
86+
87+
<!-- Header -->
88+
<tr>
89+
<td style="text-align: center">
90+
<h1
91+
style="
92+
margin: 0;
93+
font-size: 26px;
94+
font-family: 'Merriweather', serif;
95+
font-weight: 700;
96+
"
97+
>
98+
${headerTitle}
99+
</h1>
100+
<p style="margin: 10px 0 0; font-size: 16px; color: #374151">
101+
${headerSubtitle}
102+
</p>
103+
</td>
104+
</tr>
105+
106+
<!-- Body -->
107+
<tr>
108+
<td style="padding: 36px 32px">
109+
${body}
110+
</td>
111+
</tr>
112+
113+
<!-- Footer -->
114+
<tr>
115+
<td
116+
style="
117+
background: #f3f4f6;
118+
text-align: center;
119+
padding: 28px;
120+
font-size: 13px;
121+
color: #6b7280;
122+
"
123+
>
124+
<p style="margin: 0 0 4px">
125+
${footer.line1}
126+
<strong>
127+
<a
128+
style="color: #6b7280; text-decoration: none"
129+
href="${FRONTEND_HOST}"
130+
>
131+
${footer.brand}
132+
</a>
133+
</strong>.
134+
</p>
135+
<p style="margin: 6px 0 0; color: #9ca3af">
136+
${footer.line2}
137+
</p>
138+
</td>
139+
</tr>
140+
</table>
141+
</td>
142+
</tr>
143+
</table>
144+
</body>
145+
</html>
146+
`;
147+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import i18nData from "../i18n/messages.json" with { type: "json" };
2+
import { emailContainer, FRONTEND_HOST, LEADMINER_DATA_PRIVACY_URL, t } from "./base.ts";
3+
4+
export default function miningCompletedEmail(
5+
total_contacts_mined: number,
6+
total_reachable: number,
7+
total_with_phone: number,
8+
total_with_company: number,
9+
total_with_location: number,
10+
source: string | null,
11+
mining_id: string,
12+
language: "en" | "fr" = "en",
13+
): { html: string; subject: string } {
14+
const hasNoNewContacts = source === null;
15+
// deno-lint-ignore no-explicit-any
16+
const i18n = (i18nData as any)[language];
17+
18+
const subject = i18n.subject;
19+
20+
const headerSubtitle = hasNoNewContacts
21+
? i18n.noNewContactsSubtitle
22+
: t(i18n.hasNewContactsSubtitle, {
23+
total_contacts_mined,
24+
source,
25+
});
26+
27+
const bodyContent = hasNoNewContacts
28+
? `
29+
<p style="font-size: 17px; margin: 0 0 15px; text-align: center;">
30+
${i18n.noNewContactsBody}
31+
</p>
32+
33+
<table role="presentation" align="center" style="margin-top: 15px;">
34+
<tr>
35+
<td align="center" style="padding-right: 10px;">
36+
<a
37+
href="${FRONTEND_HOST}/contacts?enrich"
38+
style="
39+
display: inline-block;
40+
background: #ffd23f;
41+
color: #111827;
42+
padding: 12px 24px;
43+
border-radius: 8px;
44+
font-weight: 600;
45+
text-decoration: none;
46+
"
47+
>
48+
${i18n.buttons.enrich}
49+
</a>
50+
</td>
51+
<td align="center">
52+
<a
53+
href="${FRONTEND_HOST}/contacts"
54+
style="
55+
display: inline-block;
56+
background: #2563eb;
57+
color: #ffffff;
58+
padding: 12px 24px;
59+
border-radius: 8px;
60+
font-weight: 600;
61+
text-decoration: none;
62+
"
63+
>
64+
${i18n.buttons.viewContacts}
65+
</a>
66+
</td>
67+
</tr>
68+
</table>
69+
`
70+
: `
71+
<p style="font-size: 17px; margin: 0 0 15px">
72+
${i18n.recapIntro}
73+
</p>
74+
75+
<ul style="list-style: none; padding: 0; margin: 15px 0; font-size: 15px; line-height: 1.8;">
76+
<li>${i18n.stats.totalMined}: <strong>${total_contacts_mined}</strong></li>
77+
${total_reachable > 0 ? `<li>${i18n.stats.totalReachable}: <strong>${total_reachable}</strong></li>` : ""}
78+
${total_with_phone > 0 ? `<li>${i18n.stats.withPhone}: <strong>${total_with_phone}</strong></li>` : ""}
79+
${total_with_company > 0 ? `<li>${i18n.stats.withCompany}: <strong>${total_with_company}</strong></li>` : ""}
80+
${total_with_location > 0 ? `<li>${i18n.stats.withLocation}: <strong>${total_with_location}</strong></li>` : ""}
81+
</ul>
82+
83+
<table role="presentation" align="center" style="margin-top: 15px;">
84+
<tr>
85+
<td align="center" style="padding-right: 10px;">
86+
<a
87+
href="${FRONTEND_HOST}/contacts?enrich"
88+
style="
89+
display: inline-block;
90+
background: #ffd23f;
91+
color: #111827;
92+
padding: 12px 24px;
93+
border-radius: 8px;
94+
font-weight: 600;
95+
text-decoration: none;
96+
"
97+
>
98+
${i18n.buttons.enrich}
99+
</a>
100+
</td>
101+
<td align="center">
102+
<a
103+
href="${FRONTEND_HOST}/contacts?mining_id=${mining_id}"
104+
style="
105+
display: inline-block;
106+
background: #2563eb;
107+
color: #ffffff;
108+
padding: 12px 24px;
109+
border-radius: 8px;
110+
font-weight: 600;
111+
text-decoration: none;
112+
"
113+
>
114+
${t(i18n.buttons.viewContactsCount, { total_contacts_mined })}
115+
</a>
116+
</td>
117+
</tr>
118+
</table>
119+
120+
<div style="margin-top: 15px; font-size: 13px; color: #6b7280; text-align: center;">
121+
<div>${i18n.start_mining_toast.summary}</div>
122+
<div>${i18n.start_mining_toast.detail_1}</div>
123+
<a href="${LEADMINER_DATA_PRIVACY_URL}" style="color: #6b7280;">
124+
${i18n.start_mining_toast.detail_2}
125+
</a>
126+
</div>
127+
`;
128+
129+
const html = emailContainer({
130+
language,
131+
subject,
132+
headerTitle: i18n.headerTitle,
133+
headerSubtitle,
134+
body: bodyContent,
135+
footer: i18n.footer,
136+
});
137+
138+
return { html, subject };
139+
}

0 commit comments

Comments
 (0)