organify-email 1.1.4 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -2
- package/organify-email-1.1.2.tgz +0 -0
- package/src/brevo-client.ts +0 -438
- package/src/index.ts +0 -21
- package/src/templates.ts +0 -527
- package/tsconfig.json +0 -19
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "organify-email",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Shared email service for Organify — Brevo transactional API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
7
13
|
"scripts": {
|
|
8
14
|
"build": "tsc",
|
|
9
|
-
"dev": "tsc --watch"
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
10
17
|
},
|
|
11
18
|
"dependencies": {},
|
|
12
19
|
"devDependencies": {
|
package/organify-email-1.1.2.tgz
DELETED
|
Binary file
|
package/src/brevo-client.ts
DELETED
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────
|
|
2
|
-
// Brevo Email Client — Transactional API
|
|
3
|
-
// ─────────────────────────────────────────────
|
|
4
|
-
// Shared email service using Brevo API for all
|
|
5
|
-
// Organify microservices.
|
|
6
|
-
//
|
|
7
|
-
// Limits:
|
|
8
|
-
// - 300 emails/day (Brevo free tier)
|
|
9
|
-
// - ~30 estimated for beta
|
|
10
|
-
// - Built-in daily counter + priority system
|
|
11
|
-
//
|
|
12
|
-
// Usage:
|
|
13
|
-
// const client = new BrevoEmailClient({ apiKey, senderName, senderEmail });
|
|
14
|
-
// await client.send({ to, subject, template: 'report-ready', params });
|
|
15
|
-
// ─────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
import { emailTemplates, type EmailTemplateName } from './templates';
|
|
18
|
-
|
|
19
|
-
// ─── Types ──────────────────────────────────
|
|
20
|
-
|
|
21
|
-
export interface BrevoEmailConfig {
|
|
22
|
-
apiKey: string;
|
|
23
|
-
mailerSendApiKey?: string;
|
|
24
|
-
senderName?: string;
|
|
25
|
-
senderEmail?: string;
|
|
26
|
-
mailerSendSenderName?: string;
|
|
27
|
-
mailerSendSenderEmail?: string;
|
|
28
|
-
/** Daily send limit (default: 300) */
|
|
29
|
-
dailyLimit?: number;
|
|
30
|
-
/** Enable sending (set false for dev/test to just log) */
|
|
31
|
-
enabled?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function getEnvVar(name: string): string | undefined {
|
|
35
|
-
const value = (globalThis as any)?.process?.env?.[name];
|
|
36
|
-
if (typeof value !== 'string') return undefined;
|
|
37
|
-
const trimmed = value.trim();
|
|
38
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function parseBooleanEnv(name: string): boolean | undefined {
|
|
42
|
-
const value = getEnvVar(name)?.toLowerCase();
|
|
43
|
-
if (!value) return undefined;
|
|
44
|
-
if (value === 'true' || value === '1' || value === 'yes') return true;
|
|
45
|
-
if (value === 'false' || value === '0' || value === 'no') return false;
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Hardcoded Brevo provider configuration for package-level local/demo usage.
|
|
51
|
-
* Replace apiKey with a valid Brevo key before using in real sends.
|
|
52
|
-
*/
|
|
53
|
-
export const HARDCODED_BREVO_PROVIDER: Readonly<{
|
|
54
|
-
apiKey: string;
|
|
55
|
-
senderName: string;
|
|
56
|
-
senderEmail: string;
|
|
57
|
-
dailyLimit: number;
|
|
58
|
-
enabled: boolean;
|
|
59
|
-
}> = {
|
|
60
|
-
// Legacy fallback only. Prefer env-based configuration in createOrganifyEmailClient.
|
|
61
|
-
apiKey: '',
|
|
62
|
-
senderName: 'Organify',
|
|
63
|
-
senderEmail: 'noreply@organify.studio',
|
|
64
|
-
dailyLimit: 300,
|
|
65
|
-
enabled: false,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export type OrganifyEmailService = 'users' | 'workspaces' | 'notifications' | 'reports';
|
|
69
|
-
|
|
70
|
-
export const ORGANIFY_EMAIL_DAILY_LIMITS: Readonly<Record<OrganifyEmailService, number>> = {
|
|
71
|
-
users: 50,
|
|
72
|
-
workspaces: 50,
|
|
73
|
-
notifications: 150,
|
|
74
|
-
reports: 300,
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export function createOrganifyEmailClient(service: OrganifyEmailService): BrevoEmailClient {
|
|
78
|
-
const apiKey = getEnvVar('BREVO_API_KEY') || HARDCODED_BREVO_PROVIDER.apiKey;
|
|
79
|
-
const mailerSendApiKey = getEnvVar('MAILERSEND_API_KEY');
|
|
80
|
-
const senderName = getEnvVar('BREVO_SENDER_NAME') || HARDCODED_BREVO_PROVIDER.senderName;
|
|
81
|
-
const senderEmail = getEnvVar('BREVO_SENDER_EMAIL') || HARDCODED_BREVO_PROVIDER.senderEmail;
|
|
82
|
-
const mailerSendSenderName = getEnvVar('MAILERSEND_SENDER_NAME') || senderName;
|
|
83
|
-
const mailerSendSenderEmail = getEnvVar('MAILERSEND_SENDER_EMAIL') || senderEmail;
|
|
84
|
-
const forcedEnabled = parseBooleanEnv('BREVO_ENABLED');
|
|
85
|
-
const enabled = forcedEnabled ?? Boolean(apiKey || mailerSendApiKey);
|
|
86
|
-
|
|
87
|
-
if (!apiKey && !mailerSendApiKey) {
|
|
88
|
-
console.warn(
|
|
89
|
-
`[Email] BREVO_API_KEY and MAILERSEND_API_KEY are missing for service "${service}". Running in DRY RUN mode (emails not sent).`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return new BrevoEmailClient({
|
|
94
|
-
apiKey,
|
|
95
|
-
mailerSendApiKey,
|
|
96
|
-
senderName,
|
|
97
|
-
senderEmail,
|
|
98
|
-
mailerSendSenderName,
|
|
99
|
-
mailerSendSenderEmail,
|
|
100
|
-
dailyLimit: ORGANIFY_EMAIL_DAILY_LIMITS[service],
|
|
101
|
-
enabled,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface EmailRecipient {
|
|
106
|
-
email: string;
|
|
107
|
-
name?: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface SendEmailOptions {
|
|
111
|
-
to: EmailRecipient | EmailRecipient[];
|
|
112
|
-
subject: string;
|
|
113
|
-
/** Use a named template with dynamic params */
|
|
114
|
-
template?: EmailTemplateName;
|
|
115
|
-
/** Template parameters (dynamic variables) */
|
|
116
|
-
params?: Record<string, string | number | boolean>;
|
|
117
|
-
/** Raw HTML content (overrides template) */
|
|
118
|
-
htmlContent?: string;
|
|
119
|
-
/** Plain text fallback */
|
|
120
|
-
textContent?: string;
|
|
121
|
-
/** Priority: 'critical' always sends, 'normal' respects rate limits, 'low' may be skipped */
|
|
122
|
-
priority?: 'critical' | 'normal' | 'low';
|
|
123
|
-
/** Tags for tracking */
|
|
124
|
-
tags?: string[];
|
|
125
|
-
/** CC recipients */
|
|
126
|
-
cc?: EmailRecipient[];
|
|
127
|
-
/** BCC recipients */
|
|
128
|
-
bcc?: EmailRecipient[];
|
|
129
|
-
/** Reply-to address */
|
|
130
|
-
replyTo?: EmailRecipient;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface SendResult {
|
|
134
|
-
success: boolean;
|
|
135
|
-
messageId?: string;
|
|
136
|
-
error?: string;
|
|
137
|
-
skipped?: boolean;
|
|
138
|
-
reason?: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface DailyStats {
|
|
142
|
-
sent: number;
|
|
143
|
-
limit: number;
|
|
144
|
-
remaining: number;
|
|
145
|
-
date: string;
|
|
146
|
-
byPriority: { critical: number; normal: number; low: number };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ─── Client ─────────────────────────────────
|
|
150
|
-
|
|
151
|
-
export class BrevoEmailClient {
|
|
152
|
-
private readonly brevoApiKey: string;
|
|
153
|
-
private readonly mailerSendApiKey: string;
|
|
154
|
-
private readonly brevoSenderName: string;
|
|
155
|
-
private readonly brevoSenderEmail: string;
|
|
156
|
-
private readonly mailerSendSenderName: string;
|
|
157
|
-
private readonly mailerSendSenderEmail: string;
|
|
158
|
-
private readonly dailyLimit: number;
|
|
159
|
-
private readonly enabled: boolean;
|
|
160
|
-
|
|
161
|
-
// Daily counter (in-memory, resets on restart)
|
|
162
|
-
private dailySent = 0;
|
|
163
|
-
private dailyDate = '';
|
|
164
|
-
private dailyByPriority = { critical: 0, normal: 0, low: 0 };
|
|
165
|
-
|
|
166
|
-
private static readonly API_URL = 'https://api.brevo.com/v3/smtp/email';
|
|
167
|
-
private static readonly MAILERSEND_API_URL = 'https://api.mailersend.com/v1/email';
|
|
168
|
-
|
|
169
|
-
constructor(config: BrevoEmailConfig) {
|
|
170
|
-
this.brevoApiKey = config.apiKey;
|
|
171
|
-
this.mailerSendApiKey = config.mailerSendApiKey || '';
|
|
172
|
-
this.brevoSenderName = config.senderName || 'Organify Team';
|
|
173
|
-
this.brevoSenderEmail = config.senderEmail || 'noreply@organify.studio';
|
|
174
|
-
this.mailerSendSenderName = config.mailerSendSenderName || this.brevoSenderName;
|
|
175
|
-
this.mailerSendSenderEmail = config.mailerSendSenderEmail || this.brevoSenderEmail;
|
|
176
|
-
this.dailyLimit = config.dailyLimit || 300;
|
|
177
|
-
this.enabled = config.enabled !== false;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ─── Main Send ────────────────────────────
|
|
181
|
-
|
|
182
|
-
async send(options: SendEmailOptions): Promise<SendResult> {
|
|
183
|
-
const priority = options.priority || 'normal';
|
|
184
|
-
|
|
185
|
-
// Reset counter if new day
|
|
186
|
-
this.checkDayReset();
|
|
187
|
-
|
|
188
|
-
// Rate limit check (critical bypasses)
|
|
189
|
-
if (priority !== 'critical') {
|
|
190
|
-
if (this.dailySent >= this.dailyLimit) {
|
|
191
|
-
return {
|
|
192
|
-
success: false,
|
|
193
|
-
skipped: true,
|
|
194
|
-
reason: `Daily limit reached (${this.dailyLimit}/day). Email queued for tomorrow.`,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Reserve 10% for critical emails
|
|
199
|
-
const criticalReserve = Math.floor(this.dailyLimit * 0.1);
|
|
200
|
-
if (priority === 'low' && this.dailySent >= this.dailyLimit - criticalReserve) {
|
|
201
|
-
return {
|
|
202
|
-
success: false,
|
|
203
|
-
skipped: true,
|
|
204
|
-
reason: `Low-priority email skipped (remaining quota reserved for critical/normal).`,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Build HTML content
|
|
210
|
-
let htmlContent = options.htmlContent;
|
|
211
|
-
if (!htmlContent && options.template) {
|
|
212
|
-
const templateFn = emailTemplates[options.template];
|
|
213
|
-
if (templateFn) {
|
|
214
|
-
htmlContent = templateFn(options.params || {});
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (!htmlContent && !options.textContent) {
|
|
219
|
-
return { success: false, error: 'No content provided (template, htmlContent, or textContent required)' };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Disabled mode (dev/test)
|
|
223
|
-
if (!this.enabled) {
|
|
224
|
-
const recipients = Array.isArray(options.to) ? options.to : [options.to];
|
|
225
|
-
console.log(`[BrevoEmail] DRY RUN → ${recipients.map((r) => r.email).join(', ')} | Subject: ${options.subject}`);
|
|
226
|
-
this.incrementCounter(priority);
|
|
227
|
-
return { success: true, messageId: `dry-run-${Date.now()}`, skipped: false };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Build Brevo/MailerSend payload basis
|
|
231
|
-
const recipients = Array.isArray(options.to) ? options.to : [options.to];
|
|
232
|
-
const recipientPayload = recipients.map((r) => ({ email: r.email, name: r.name }));
|
|
233
|
-
|
|
234
|
-
if (!this.brevoApiKey && !this.mailerSendApiKey) {
|
|
235
|
-
return { success: false, error: 'No email provider key configured (BREVO_API_KEY or MAILERSEND_API_KEY)' };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (this.brevoApiKey) {
|
|
239
|
-
const brevoResult = await this.sendViaBrevo({
|
|
240
|
-
recipients: recipientPayload,
|
|
241
|
-
subject: options.subject,
|
|
242
|
-
htmlContent,
|
|
243
|
-
textContent: options.textContent,
|
|
244
|
-
params: options.params,
|
|
245
|
-
tags: options.tags,
|
|
246
|
-
cc: options.cc,
|
|
247
|
-
bcc: options.bcc,
|
|
248
|
-
replyTo: options.replyTo,
|
|
249
|
-
});
|
|
250
|
-
if (brevoResult.success) {
|
|
251
|
-
this.incrementCounter(priority);
|
|
252
|
-
return brevoResult;
|
|
253
|
-
}
|
|
254
|
-
console.warn(`[Email] Brevo send failed; trying MailerSend fallback: ${brevoResult.error || 'unknown error'}`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (this.mailerSendApiKey) {
|
|
258
|
-
const mailerSendResult = await this.sendViaMailerSend({
|
|
259
|
-
recipients: recipientPayload,
|
|
260
|
-
subject: options.subject,
|
|
261
|
-
htmlContent,
|
|
262
|
-
textContent: options.textContent,
|
|
263
|
-
cc: options.cc,
|
|
264
|
-
bcc: options.bcc,
|
|
265
|
-
replyTo: options.replyTo,
|
|
266
|
-
});
|
|
267
|
-
if (mailerSendResult.success) {
|
|
268
|
-
this.incrementCounter(priority);
|
|
269
|
-
}
|
|
270
|
-
return mailerSendResult;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return { success: false, error: 'Brevo failed and MAILERSEND_API_KEY is not configured for fallback' };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
private async sendViaBrevo(options: {
|
|
277
|
-
recipients: Array<{ email: string; name?: string }>;
|
|
278
|
-
subject: string;
|
|
279
|
-
htmlContent?: string;
|
|
280
|
-
textContent?: string;
|
|
281
|
-
params?: Record<string, string | number | boolean>;
|
|
282
|
-
tags?: string[];
|
|
283
|
-
cc?: EmailRecipient[];
|
|
284
|
-
bcc?: EmailRecipient[];
|
|
285
|
-
replyTo?: EmailRecipient;
|
|
286
|
-
}): Promise<SendResult> {
|
|
287
|
-
const payload: Record<string, unknown> = {
|
|
288
|
-
sender: { name: this.brevoSenderName, email: this.brevoSenderEmail },
|
|
289
|
-
to: options.recipients,
|
|
290
|
-
subject: options.subject,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
if (options.htmlContent) payload.htmlContent = options.htmlContent;
|
|
294
|
-
else if (options.textContent) payload.textContent = options.textContent;
|
|
295
|
-
if (options.params) payload.params = options.params;
|
|
296
|
-
if (options.tags) payload.tags = options.tags;
|
|
297
|
-
if (options.cc) payload.cc = options.cc;
|
|
298
|
-
if (options.bcc) payload.bcc = options.bcc;
|
|
299
|
-
if (options.replyTo) payload.replyTo = options.replyTo;
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
const response = await fetch(BrevoEmailClient.API_URL, {
|
|
303
|
-
method: 'POST',
|
|
304
|
-
headers: {
|
|
305
|
-
accept: 'application/json',
|
|
306
|
-
'api-key': this.brevoApiKey,
|
|
307
|
-
'content-type': 'application/json',
|
|
308
|
-
},
|
|
309
|
-
body: JSON.stringify(payload),
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
if (!response.ok) {
|
|
313
|
-
const body = await response.text();
|
|
314
|
-
console.error(`[BrevoEmail] API error ${response.status}: ${body}`);
|
|
315
|
-
if (response.status === 401) {
|
|
316
|
-
console.error('[BrevoEmail] Check BREVO_API_KEY in env and ensure it is enabled for Transactional API/SMTP.');
|
|
317
|
-
}
|
|
318
|
-
return { success: false, error: `Brevo API error: ${response.status} — ${body}` };
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const data = await response.json() as { messageId?: string };
|
|
322
|
-
return { success: true, messageId: data.messageId };
|
|
323
|
-
} catch (err: unknown) {
|
|
324
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
-
console.error(`[BrevoEmail] Network error: ${message}`);
|
|
326
|
-
return { success: false, error: `Brevo network error: ${message}` };
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private async sendViaMailerSend(options: {
|
|
331
|
-
recipients: Array<{ email: string; name?: string }>;
|
|
332
|
-
subject: string;
|
|
333
|
-
htmlContent?: string;
|
|
334
|
-
textContent?: string;
|
|
335
|
-
cc?: EmailRecipient[];
|
|
336
|
-
bcc?: EmailRecipient[];
|
|
337
|
-
replyTo?: EmailRecipient;
|
|
338
|
-
}): Promise<SendResult> {
|
|
339
|
-
const payload: Record<string, unknown> = {
|
|
340
|
-
from: { email: this.mailerSendSenderEmail, name: this.mailerSendSenderName },
|
|
341
|
-
to: options.recipients,
|
|
342
|
-
subject: options.subject,
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
if (options.htmlContent) payload.html = options.htmlContent;
|
|
346
|
-
if (options.textContent) payload.text = options.textContent;
|
|
347
|
-
if (!options.textContent && options.htmlContent) {
|
|
348
|
-
payload.text = 'Mensagem enviada pelo Organify.';
|
|
349
|
-
}
|
|
350
|
-
if (options.cc) payload.cc = options.cc;
|
|
351
|
-
if (options.bcc) payload.bcc = options.bcc;
|
|
352
|
-
if (options.replyTo) {
|
|
353
|
-
payload.reply_to = {
|
|
354
|
-
email: options.replyTo.email,
|
|
355
|
-
name: options.replyTo.name,
|
|
356
|
-
};
|
|
357
|
-
} else {
|
|
358
|
-
payload.reply_to = {
|
|
359
|
-
email: this.mailerSendSenderEmail,
|
|
360
|
-
name: this.mailerSendSenderName,
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
const response = await fetch(BrevoEmailClient.MAILERSEND_API_URL, {
|
|
366
|
-
method: 'POST',
|
|
367
|
-
headers: {
|
|
368
|
-
Authorization: `Bearer ${this.mailerSendApiKey}`,
|
|
369
|
-
'Content-Type': 'application/json',
|
|
370
|
-
},
|
|
371
|
-
body: JSON.stringify(payload),
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
if (!response.ok) {
|
|
375
|
-
const body = await response.text();
|
|
376
|
-
console.error(`[MailerSendEmail] API error ${response.status}: ${body}`);
|
|
377
|
-
return { success: false, error: `MailerSend API error: ${response.status} — ${body}` };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const messageId = response.headers.get('x-message-id') || undefined;
|
|
381
|
-
return { success: true, messageId };
|
|
382
|
-
} catch (err: unknown) {
|
|
383
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
384
|
-
console.error(`[MailerSendEmail] Network error: ${message}`);
|
|
385
|
-
return { success: false, error: `MailerSend network error: ${message}` };
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// ─── Batch Send (same template, multiple recipients) ──
|
|
390
|
-
|
|
391
|
-
async sendBatch(
|
|
392
|
-
recipients: EmailRecipient[],
|
|
393
|
-
options: Omit<SendEmailOptions, 'to'>,
|
|
394
|
-
): Promise<SendResult[]> {
|
|
395
|
-
// Brevo supports multiple "to" in a single request
|
|
396
|
-
// But for individual params, send separately
|
|
397
|
-
if (options.params) {
|
|
398
|
-
// Each recipient gets their own email
|
|
399
|
-
const results: SendResult[] = [];
|
|
400
|
-
for (const recipient of recipients) {
|
|
401
|
-
results.push(await this.send({ ...options, to: recipient }));
|
|
402
|
-
}
|
|
403
|
-
return results;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Single request with multiple recipients
|
|
407
|
-
return [await this.send({ ...options, to: recipients })];
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// ─── Stats ────────────────────────────────
|
|
411
|
-
|
|
412
|
-
getStats(): DailyStats {
|
|
413
|
-
this.checkDayReset();
|
|
414
|
-
return {
|
|
415
|
-
sent: this.dailySent,
|
|
416
|
-
limit: this.dailyLimit,
|
|
417
|
-
remaining: Math.max(0, this.dailyLimit - this.dailySent),
|
|
418
|
-
date: this.dailyDate,
|
|
419
|
-
byPriority: { ...this.dailyByPriority },
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// ─── Internal ─────────────────────────────
|
|
424
|
-
|
|
425
|
-
private checkDayReset() {
|
|
426
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
427
|
-
if (this.dailyDate !== today) {
|
|
428
|
-
this.dailySent = 0;
|
|
429
|
-
this.dailyDate = today;
|
|
430
|
-
this.dailyByPriority = { critical: 0, normal: 0, low: 0 };
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
private incrementCounter(priority: 'critical' | 'normal' | 'low') {
|
|
435
|
-
this.dailySent++;
|
|
436
|
-
this.dailyByPriority[priority]++;
|
|
437
|
-
}
|
|
438
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────
|
|
2
|
-
// organify-email — Public API
|
|
3
|
-
// ─────────────────────────────────────────────
|
|
4
|
-
|
|
5
|
-
export {
|
|
6
|
-
BrevoEmailClient,
|
|
7
|
-
HARDCODED_BREVO_PROVIDER,
|
|
8
|
-
ORGANIFY_EMAIL_DAILY_LIMITS,
|
|
9
|
-
createOrganifyEmailClient,
|
|
10
|
-
type OrganifyEmailService,
|
|
11
|
-
type BrevoEmailConfig,
|
|
12
|
-
type EmailRecipient,
|
|
13
|
-
type SendEmailOptions,
|
|
14
|
-
type SendResult,
|
|
15
|
-
type DailyStats,
|
|
16
|
-
} from './brevo-client';
|
|
17
|
-
|
|
18
|
-
export {
|
|
19
|
-
emailTemplates,
|
|
20
|
-
type EmailTemplateName,
|
|
21
|
-
} from './templates';
|
package/src/templates.ts
DELETED
|
@@ -1,527 +0,0 @@
|
|
|
1
|
-
// ─────────────────────────────────────────────
|
|
2
|
-
// Email Templates — Dark Mode, Organify Brand
|
|
3
|
-
// ─────────────────────────────────────────────
|
|
4
|
-
// Professional dark-theme HTML emails matching
|
|
5
|
-
// the Organify frontend design system.
|
|
6
|
-
//
|
|
7
|
-
// Brand Colors:
|
|
8
|
-
// Primary: #7C3AED (Vibrant Violet)
|
|
9
|
-
// Glow: #8B5CF6
|
|
10
|
-
// Light: #A78BFA
|
|
11
|
-
// Surface: #110E22
|
|
12
|
-
// Void: #0D0A1A
|
|
13
|
-
// Elevated: #1A1530
|
|
14
|
-
// Success: #10B981
|
|
15
|
-
// Warning: #F59E0B
|
|
16
|
-
// Error: #EF4444
|
|
17
|
-
//
|
|
18
|
-
// Design Rules:
|
|
19
|
-
// - Dark mode only
|
|
20
|
-
// - Organify logo (frontend geometric mark, no external images)
|
|
21
|
-
// - No emojis
|
|
22
|
-
// - Professional, clean, Space Grotesk feel
|
|
23
|
-
// - Glass-morphism borders (rgba white)
|
|
24
|
-
// ─────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
export type EmailTemplateName =
|
|
27
|
-
| 'report-ready'
|
|
28
|
-
| 'welcome'
|
|
29
|
-
| 'password-reset'
|
|
30
|
-
| 'workspace-invite'
|
|
31
|
-
| 'sprint-digest'
|
|
32
|
-
| 'task-assigned'
|
|
33
|
-
| 'task-mentioned'
|
|
34
|
-
| 'project-update'
|
|
35
|
-
| 'weekly-digest';
|
|
36
|
-
|
|
37
|
-
type TemplateFunction = (params: Record<string, any>) => string;
|
|
38
|
-
|
|
39
|
-
// ─── Shared Layout ──────────────────────────
|
|
40
|
-
|
|
41
|
-
function baseLayout(content: string, preheader?: string): string {
|
|
42
|
-
return `<!DOCTYPE html>
|
|
43
|
-
<html lang="pt" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
44
|
-
<head>
|
|
45
|
-
<meta charset="utf-8" />
|
|
46
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
47
|
-
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
48
|
-
<meta name="color-scheme" content="dark" />
|
|
49
|
-
<meta name="supported-color-schemes" content="dark" />
|
|
50
|
-
<title>Organify</title>
|
|
51
|
-
<!--[if mso]>
|
|
52
|
-
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
|
53
|
-
<![endif]-->
|
|
54
|
-
<style>
|
|
55
|
-
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
|
56
|
-
|
|
57
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
58
|
-
body, table, td { font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
59
|
-
body { background-color: #0D0A1A; color: #E8E5F0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
|
60
|
-
img { border: 0; outline: none; text-decoration: none; display: block; }
|
|
61
|
-
a { color: #A78BFA; text-decoration: none; }
|
|
62
|
-
a:hover { color: #C4B5FD; }
|
|
63
|
-
|
|
64
|
-
@media only screen and (max-width: 620px) {
|
|
65
|
-
.container { width: 100% !important; padding: 16px !important; }
|
|
66
|
-
.content-cell { padding: 24px 20px !important; }
|
|
67
|
-
.btn { width: 100% !important; display: block !important; }
|
|
68
|
-
}
|
|
69
|
-
</style>
|
|
70
|
-
</head>
|
|
71
|
-
<body style="margin:0;padding:0;background-color:#0D0A1A;">
|
|
72
|
-
${preheader ? `<div style="display:none;font-size:1px;color:#0D0A1A;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${preheader}</div>` : ''}
|
|
73
|
-
|
|
74
|
-
<!-- Wrapper -->
|
|
75
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:#0D0A1A;">
|
|
76
|
-
<tr>
|
|
77
|
-
<td align="center" style="padding:40px 16px;">
|
|
78
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="580" class="container" style="max-width:580px;width:100%;">
|
|
79
|
-
|
|
80
|
-
<!-- Logo Header -->
|
|
81
|
-
<tr>
|
|
82
|
-
<td align="center" style="padding-bottom:32px;">
|
|
83
|
-
<table role="presentation" cellpadding="0" cellspacing="0">
|
|
84
|
-
<tr>
|
|
85
|
-
<td style="padding-right:12px;vertical-align:middle;">
|
|
86
|
-
<div style="position:relative;width:40px;height:40px;border-radius:12px;border:2px solid rgba(124,58,237,0.35);background:linear-gradient(135deg,rgba(124,58,237,0.16),rgba(124,58,237,0.02));box-shadow:0 0 20px rgba(124,58,237,0.2);">
|
|
87
|
-
<div style="position:absolute;top:50%;left:50%;width:16px;height:16px;transform:translate(-50%,-50%) rotate(45deg);border-radius:6px;background:linear-gradient(135deg,#7C3AED,#A78BFA);box-shadow:0 0 12px rgba(124,58,237,0.55);"></div>
|
|
88
|
-
<div style="position:absolute;right:-2px;top:-2px;width:10px;height:10px;border-right:2px solid rgba(124,58,237,0.45);border-top:2px solid rgba(124,58,237,0.45);border-top-right-radius:6px;"></div>
|
|
89
|
-
</div>
|
|
90
|
-
</td>
|
|
91
|
-
<td style="vertical-align:middle;">
|
|
92
|
-
<span style="font-size:22px;font-weight:700;color:#FFFFFF;letter-spacing:-0.5px;">Organify</span>
|
|
93
|
-
</td>
|
|
94
|
-
</tr>
|
|
95
|
-
</table>
|
|
96
|
-
</td>
|
|
97
|
-
</tr>
|
|
98
|
-
|
|
99
|
-
<!-- Main Card -->
|
|
100
|
-
<tr>
|
|
101
|
-
<td>
|
|
102
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:#110E22;border-radius:16px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;">
|
|
103
|
-
<tr>
|
|
104
|
-
<td class="content-cell" style="padding:36px 32px;">
|
|
105
|
-
${content}
|
|
106
|
-
</td>
|
|
107
|
-
</tr>
|
|
108
|
-
</table>
|
|
109
|
-
</td>
|
|
110
|
-
</tr>
|
|
111
|
-
|
|
112
|
-
<!-- Footer -->
|
|
113
|
-
<tr>
|
|
114
|
-
<td style="padding-top:32px;text-align:center;">
|
|
115
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.4);line-height:1.6;margin:0;">
|
|
116
|
-
Organify — Project Management Intelligence
|
|
117
|
-
</p>
|
|
118
|
-
<p style="font-size:11px;color:rgba(232,229,240,0.25);line-height:1.5;margin-top:8px;">
|
|
119
|
-
Este email foi enviado automaticamente. Por favor nao responda directamente.
|
|
120
|
-
</p>
|
|
121
|
-
</td>
|
|
122
|
-
</tr>
|
|
123
|
-
|
|
124
|
-
</table>
|
|
125
|
-
</td>
|
|
126
|
-
</tr>
|
|
127
|
-
</table>
|
|
128
|
-
</body>
|
|
129
|
-
</html>`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ─── Reusable Components ────────────────────
|
|
133
|
-
|
|
134
|
-
function primaryButton(text: string, url: string): string {
|
|
135
|
-
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 8px;">
|
|
136
|
-
<tr>
|
|
137
|
-
<td style="border-radius:10px;background:linear-gradient(135deg,#7C3AED,#8B5CF6);padding:1px;">
|
|
138
|
-
<a href="${url}" target="_blank" class="btn" style="display:inline-block;padding:12px 32px;border-radius:9px;background-color:#1A1530;color:#A78BFA;font-size:14px;font-weight:600;text-decoration:none;letter-spacing:0.3px;">
|
|
139
|
-
${text}
|
|
140
|
-
</a>
|
|
141
|
-
</td>
|
|
142
|
-
</tr>
|
|
143
|
-
</table>`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function metricCard(label: string, value: string, color?: string): string {
|
|
147
|
-
const c = color || '#A78BFA';
|
|
148
|
-
return `<td style="padding:8px;">
|
|
149
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(255,255,255,0.04);border-radius:10px;border:1px solid rgba(255,255,255,0.06);">
|
|
150
|
-
<tr>
|
|
151
|
-
<td style="padding:16px;text-align:center;">
|
|
152
|
-
<p style="font-size:24px;font-weight:700;color:${c};margin:0;line-height:1.2;">${value}</p>
|
|
153
|
-
<p style="font-size:11px;color:rgba(232,229,240,0.5);margin-top:6px;text-transform:uppercase;letter-spacing:0.8px;">${label}</p>
|
|
154
|
-
</td>
|
|
155
|
-
</tr>
|
|
156
|
-
</table>
|
|
157
|
-
</td>`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function divider(): string {
|
|
161
|
-
return `<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin:24px 0;">
|
|
162
|
-
<tr><td style="border-top:1px solid rgba(255,255,255,0.06);"></td></tr>
|
|
163
|
-
</table>`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function heading(text: string): string {
|
|
167
|
-
return `<h1 style="font-size:22px;font-weight:700;color:#FFFFFF;margin:0 0 8px;line-height:1.3;">${text}</h1>`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function subtext(text: string): string {
|
|
171
|
-
return `<p style="font-size:14px;color:rgba(232,229,240,0.6);line-height:1.6;margin:0;">${text}</p>`;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function badge(text: string, bgColor: string, textColor: string): string {
|
|
175
|
-
return `<span style="display:inline-block;padding:4px 12px;border-radius:6px;background-color:${bgColor};color:${textColor};font-size:12px;font-weight:600;letter-spacing:0.3px;">${text}</span>`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ─── Templates ──────────────────────────────
|
|
179
|
-
|
|
180
|
-
const reportReady: TemplateFunction = (params) => {
|
|
181
|
-
const {
|
|
182
|
-
userName = 'Utilizador',
|
|
183
|
-
reportTitle = 'Relatorio',
|
|
184
|
-
reportType = 'SPRINT',
|
|
185
|
-
projectName = '',
|
|
186
|
-
downloadUrl = '#',
|
|
187
|
-
completionTime = '',
|
|
188
|
-
fileSize = '',
|
|
189
|
-
} = params;
|
|
190
|
-
|
|
191
|
-
return baseLayout(`
|
|
192
|
-
${heading('Relatorio Pronto')}
|
|
193
|
-
${subtext(`Ola ${userName}, o seu relatorio foi gerado com sucesso.`)}
|
|
194
|
-
|
|
195
|
-
${divider()}
|
|
196
|
-
|
|
197
|
-
<!-- Report Info -->
|
|
198
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(124,58,237,0.08);border-radius:12px;border:1px solid rgba(124,58,237,0.15);">
|
|
199
|
-
<tr>
|
|
200
|
-
<td style="padding:20px 24px;">
|
|
201
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
|
202
|
-
<tr>
|
|
203
|
-
<td>
|
|
204
|
-
<p style="font-size:11px;color:rgba(232,229,240,0.4);text-transform:uppercase;letter-spacing:0.8px;margin:0 0 6px;">Relatorio</p>
|
|
205
|
-
<p style="font-size:16px;font-weight:600;color:#FFFFFF;margin:0;">${reportTitle}</p>
|
|
206
|
-
</td>
|
|
207
|
-
<td align="right" style="vertical-align:top;">
|
|
208
|
-
${badge(reportType, 'rgba(139,92,246,0.15)', '#A78BFA')}
|
|
209
|
-
</td>
|
|
210
|
-
</tr>
|
|
211
|
-
${projectName ? `<tr><td colspan="2" style="padding-top:12px;">
|
|
212
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0;">Projecto: <span style="color:#E8E5F0;">${projectName}</span></p>
|
|
213
|
-
</td></tr>` : ''}
|
|
214
|
-
</table>
|
|
215
|
-
</td>
|
|
216
|
-
</tr>
|
|
217
|
-
</table>
|
|
218
|
-
|
|
219
|
-
<!-- Metrics Row -->
|
|
220
|
-
${completionTime || fileSize ? `
|
|
221
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top:16px;">
|
|
222
|
-
<tr>
|
|
223
|
-
${completionTime ? metricCard('Tempo de geracao', completionTime, '#10B981') : ''}
|
|
224
|
-
${fileSize ? metricCard('Tamanho', fileSize, '#A78BFA') : ''}
|
|
225
|
-
</tr>
|
|
226
|
-
</table>` : ''}
|
|
227
|
-
|
|
228
|
-
${primaryButton('Ver Relatorio', downloadUrl)}
|
|
229
|
-
|
|
230
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.35);margin-top:16px;line-height:1.5;">
|
|
231
|
-
O relatorio esta disponivel durante 7 dias. Faca download antes da expiracao.
|
|
232
|
-
</p>
|
|
233
|
-
`, `O seu relatorio "${reportTitle}" esta pronto para download.`);
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const welcome: TemplateFunction = (params) => {
|
|
237
|
-
const {
|
|
238
|
-
userName = 'Utilizador',
|
|
239
|
-
workspaceName = '',
|
|
240
|
-
loginUrl = '#',
|
|
241
|
-
} = params;
|
|
242
|
-
|
|
243
|
-
return baseLayout(`
|
|
244
|
-
${heading('Bem-vindo ao Organify')}
|
|
245
|
-
${subtext(`Ola ${userName}, a sua conta foi criada com sucesso.`)}
|
|
246
|
-
|
|
247
|
-
${divider()}
|
|
248
|
-
|
|
249
|
-
<p style="font-size:14px;color:rgba(232,229,240,0.7);line-height:1.7;margin:0;">
|
|
250
|
-
O Organify e a plataforma de gestao de projectos com inteligencia integrada.
|
|
251
|
-
Comece por explorar o seu workspace${workspaceName ? ` <strong style="color:#FFFFFF;">${workspaceName}</strong>` : ''} e criar o primeiro projecto.
|
|
252
|
-
</p>
|
|
253
|
-
|
|
254
|
-
<!-- Features -->
|
|
255
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top:20px;">
|
|
256
|
-
<tr>
|
|
257
|
-
${metricCard('Boards', 'Kanban & Scrum', '#8B5CF6')}
|
|
258
|
-
${metricCard('Reports', 'Analytics', '#10B981')}
|
|
259
|
-
${metricCard('AI', 'Assistente', '#F59E0B')}
|
|
260
|
-
</tr>
|
|
261
|
-
</table>
|
|
262
|
-
|
|
263
|
-
${primaryButton('Aceder ao Organify', loginUrl)}
|
|
264
|
-
`, `Bem-vindo ao Organify, ${userName}! A sua conta esta pronta.`);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const passwordReset: TemplateFunction = (params) => {
|
|
268
|
-
const {
|
|
269
|
-
userName = 'Utilizador',
|
|
270
|
-
resetUrl = '#',
|
|
271
|
-
expiresIn = '1 hora',
|
|
272
|
-
} = params;
|
|
273
|
-
|
|
274
|
-
return baseLayout(`
|
|
275
|
-
${heading('Recuperacao de Palavra-passe')}
|
|
276
|
-
${subtext(`Ola ${userName}, recebemos um pedido para redefinir a sua palavra-passe.`)}
|
|
277
|
-
|
|
278
|
-
${divider()}
|
|
279
|
-
|
|
280
|
-
<p style="font-size:14px;color:rgba(232,229,240,0.7);line-height:1.7;margin:0;">
|
|
281
|
-
Clique no botao abaixo para criar uma nova palavra-passe.
|
|
282
|
-
Este link expira em <strong style="color:#F59E0B;">${expiresIn}</strong>.
|
|
283
|
-
</p>
|
|
284
|
-
|
|
285
|
-
${primaryButton('Redefinir Palavra-passe', resetUrl)}
|
|
286
|
-
|
|
287
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.35);margin-top:20px;line-height:1.5;">
|
|
288
|
-
Se nao solicitou esta alteracao, ignore este email. A sua palavra-passe permanece inalterada.
|
|
289
|
-
</p>
|
|
290
|
-
`, 'Pedido de recuperacao de palavra-passe no Organify.');
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const workspaceInvite: TemplateFunction = (params) => {
|
|
294
|
-
const {
|
|
295
|
-
inviterName = 'Alguem',
|
|
296
|
-
workspaceName = 'Workspace',
|
|
297
|
-
role = 'membro',
|
|
298
|
-
inviteUrl = '#',
|
|
299
|
-
} = params;
|
|
300
|
-
|
|
301
|
-
return baseLayout(`
|
|
302
|
-
${heading('Convite de Workspace')}
|
|
303
|
-
${subtext(`${inviterName} convidou-o para o workspace <strong style="color:#FFFFFF;">${workspaceName}</strong>.`)}
|
|
304
|
-
|
|
305
|
-
${divider()}
|
|
306
|
-
|
|
307
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(124,58,237,0.08);border-radius:12px;border:1px solid rgba(124,58,237,0.15);">
|
|
308
|
-
<tr>
|
|
309
|
-
<td style="padding:20px 24px;">
|
|
310
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
|
311
|
-
<tr>
|
|
312
|
-
<td>
|
|
313
|
-
<p style="font-size:11px;color:rgba(232,229,240,0.4);text-transform:uppercase;letter-spacing:0.8px;margin:0 0 6px;">Workspace</p>
|
|
314
|
-
<p style="font-size:16px;font-weight:600;color:#FFFFFF;margin:0;">${workspaceName}</p>
|
|
315
|
-
</td>
|
|
316
|
-
<td align="right" style="vertical-align:top;">
|
|
317
|
-
${badge(role.toUpperCase(), 'rgba(16,185,129,0.15)', '#10B981')}
|
|
318
|
-
</td>
|
|
319
|
-
</tr>
|
|
320
|
-
<tr>
|
|
321
|
-
<td colspan="2" style="padding-top:8px;">
|
|
322
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0;">Convidado por: <span style="color:#E8E5F0;">${inviterName}</span></p>
|
|
323
|
-
</td>
|
|
324
|
-
</tr>
|
|
325
|
-
</table>
|
|
326
|
-
</td>
|
|
327
|
-
</tr>
|
|
328
|
-
</table>
|
|
329
|
-
|
|
330
|
-
${primaryButton('Aceitar Convite', inviteUrl)}
|
|
331
|
-
`, `${inviterName} convidou-o para o workspace ${workspaceName} no Organify.`);
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
const sprintDigest: TemplateFunction = (params) => {
|
|
335
|
-
const {
|
|
336
|
-
sprintName = 'Sprint',
|
|
337
|
-
projectName = 'Projecto',
|
|
338
|
-
completionRate = '0%',
|
|
339
|
-
tasksCompleted = '0',
|
|
340
|
-
totalTasks = '0',
|
|
341
|
-
velocity = '0',
|
|
342
|
-
daysRemaining = '0',
|
|
343
|
-
dashboardUrl = '#',
|
|
344
|
-
} = params;
|
|
345
|
-
|
|
346
|
-
const completionNum = parseInt(String(completionRate));
|
|
347
|
-
const statusColor = completionNum >= 75 ? '#10B981' : completionNum >= 40 ? '#F59E0B' : '#EF4444';
|
|
348
|
-
|
|
349
|
-
return baseLayout(`
|
|
350
|
-
${heading('Resumo da Sprint')}
|
|
351
|
-
${subtext(`${sprintName} — ${projectName}`)}
|
|
352
|
-
|
|
353
|
-
${divider()}
|
|
354
|
-
|
|
355
|
-
<!-- Progress Bar -->
|
|
356
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-bottom:16px;">
|
|
357
|
-
<tr>
|
|
358
|
-
<td>
|
|
359
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0 0 8px;">Progresso</p>
|
|
360
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(255,255,255,0.06);border-radius:6px;overflow:hidden;">
|
|
361
|
-
<tr>
|
|
362
|
-
<td style="width:${completionRate};background:linear-gradient(90deg,#7C3AED,#8B5CF6);height:8px;border-radius:6px;"></td>
|
|
363
|
-
<td style="height:8px;"></td>
|
|
364
|
-
</tr>
|
|
365
|
-
</table>
|
|
366
|
-
<p style="font-size:13px;font-weight:600;color:${statusColor};margin-top:6px;">${completionRate} concluido</p>
|
|
367
|
-
</td>
|
|
368
|
-
</tr>
|
|
369
|
-
</table>
|
|
370
|
-
|
|
371
|
-
<!-- Metrics -->
|
|
372
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
|
373
|
-
<tr>
|
|
374
|
-
${metricCard('Concluidas', `${tasksCompleted}/${totalTasks}`, '#10B981')}
|
|
375
|
-
${metricCard('Velocity', `${velocity} SP`, '#A78BFA')}
|
|
376
|
-
${metricCard('Dias Restantes', daysRemaining, completionNum >= 75 ? '#10B981' : '#F59E0B')}
|
|
377
|
-
</tr>
|
|
378
|
-
</table>
|
|
379
|
-
|
|
380
|
-
${primaryButton('Ver Dashboard', dashboardUrl)}
|
|
381
|
-
`, `${sprintName}: ${completionRate} concluida.`);
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
const taskAssigned: TemplateFunction = (params) => {
|
|
385
|
-
const {
|
|
386
|
-
userName = 'Utilizador',
|
|
387
|
-
taskTitle = 'Tarefa',
|
|
388
|
-
projectName = '',
|
|
389
|
-
assignerName = '',
|
|
390
|
-
priority = '',
|
|
391
|
-
taskUrl = '#',
|
|
392
|
-
} = params;
|
|
393
|
-
|
|
394
|
-
const priorityColors: Record<string, [string, string]> = {
|
|
395
|
-
critical: ['rgba(239,68,68,0.15)', '#EF4444'],
|
|
396
|
-
high: ['rgba(245,158,11,0.15)', '#F59E0B'],
|
|
397
|
-
medium: ['rgba(167,139,250,0.15)', '#A78BFA'],
|
|
398
|
-
low: ['rgba(16,185,129,0.15)', '#10B981'],
|
|
399
|
-
};
|
|
400
|
-
const [bg, tc] = priorityColors[priority] || ['rgba(167,139,250,0.15)', '#A78BFA'];
|
|
401
|
-
|
|
402
|
-
return baseLayout(`
|
|
403
|
-
${heading('Nova Tarefa Atribuida')}
|
|
404
|
-
${subtext(`Ola ${userName}, foi-lhe atribuida uma nova tarefa.`)}
|
|
405
|
-
|
|
406
|
-
${divider()}
|
|
407
|
-
|
|
408
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(255,255,255,0.03);border-radius:12px;border:1px solid rgba(255,255,255,0.06);">
|
|
409
|
-
<tr>
|
|
410
|
-
<td style="padding:20px 24px;">
|
|
411
|
-
<p style="font-size:16px;font-weight:600;color:#FFFFFF;margin:0 0 8px;">${taskTitle}</p>
|
|
412
|
-
${projectName ? `<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0 0 8px;">Projecto: <span style="color:#E8E5F0;">${projectName}</span></p>` : ''}
|
|
413
|
-
${assignerName ? `<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0 0 8px;">Atribuida por: <span style="color:#E8E5F0;">${assignerName}</span></p>` : ''}
|
|
414
|
-
${priority ? `<p style="margin:8px 0 0;">${badge(priority.toUpperCase(), bg, tc)}</p>` : ''}
|
|
415
|
-
</td>
|
|
416
|
-
</tr>
|
|
417
|
-
</table>
|
|
418
|
-
|
|
419
|
-
${primaryButton('Ver Tarefa', taskUrl)}
|
|
420
|
-
`, `Nova tarefa atribuida: ${taskTitle}`);
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const taskMentioned: TemplateFunction = (params) => {
|
|
424
|
-
const {
|
|
425
|
-
userName = 'Utilizador',
|
|
426
|
-
mentionerName = 'Alguem',
|
|
427
|
-
taskTitle = 'Tarefa',
|
|
428
|
-
commentPreview = '',
|
|
429
|
-
taskUrl = '#',
|
|
430
|
-
} = params;
|
|
431
|
-
|
|
432
|
-
return baseLayout(`
|
|
433
|
-
${heading('Mencionado num Comentario')}
|
|
434
|
-
${subtext(`Ola ${userName}, ${mentionerName} mencionou-o numa tarefa.`)}
|
|
435
|
-
|
|
436
|
-
${divider()}
|
|
437
|
-
|
|
438
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:rgba(255,255,255,0.03);border-radius:12px;border:1px solid rgba(255,255,255,0.06);">
|
|
439
|
-
<tr>
|
|
440
|
-
<td style="padding:20px 24px;">
|
|
441
|
-
<p style="font-size:14px;font-weight:600;color:#FFFFFF;margin:0 0 8px;">${taskTitle}</p>
|
|
442
|
-
${commentPreview ? `
|
|
443
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top:8px;">
|
|
444
|
-
<tr>
|
|
445
|
-
<td style="border-left:3px solid #7C3AED;padding-left:12px;">
|
|
446
|
-
<p style="font-size:13px;color:rgba(232,229,240,0.6);line-height:1.5;margin:0;font-style:italic;">"${commentPreview}"</p>
|
|
447
|
-
</td>
|
|
448
|
-
</tr>
|
|
449
|
-
</table>` : ''}
|
|
450
|
-
</td>
|
|
451
|
-
</tr>
|
|
452
|
-
</table>
|
|
453
|
-
|
|
454
|
-
${primaryButton('Ver Comentario', taskUrl)}
|
|
455
|
-
`, `${mentionerName} mencionou-o na tarefa "${taskTitle}"`);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const projectUpdate: TemplateFunction = (params) => {
|
|
459
|
-
const {
|
|
460
|
-
userName = 'Utilizador',
|
|
461
|
-
projectName = 'Projecto',
|
|
462
|
-
updateType = 'actualizacao',
|
|
463
|
-
summary = '',
|
|
464
|
-
projectUrl = '#',
|
|
465
|
-
} = params;
|
|
466
|
-
|
|
467
|
-
return baseLayout(`
|
|
468
|
-
${heading('Actualizacao de Projecto')}
|
|
469
|
-
${subtext(`Ola ${userName}, ha uma ${updateType} no projecto <strong style="color:#FFFFFF;">${projectName}</strong>.`)}
|
|
470
|
-
|
|
471
|
-
${divider()}
|
|
472
|
-
|
|
473
|
-
${summary ? `<p style="font-size:14px;color:rgba(232,229,240,0.7);line-height:1.7;margin:0;">${summary}</p>` : ''}
|
|
474
|
-
|
|
475
|
-
${primaryButton('Ver Projecto', projectUrl)}
|
|
476
|
-
`, `Actualizacao no projecto ${projectName}`);
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
const weeklyDigest: TemplateFunction = (params) => {
|
|
480
|
-
const {
|
|
481
|
-
userName = 'Utilizador',
|
|
482
|
-
weekLabel = 'Esta semana',
|
|
483
|
-
tasksCompleted = '0',
|
|
484
|
-
tasksCreated = '0',
|
|
485
|
-
activeProjects = '0',
|
|
486
|
-
topProject = '',
|
|
487
|
-
dashboardUrl = '#',
|
|
488
|
-
} = params;
|
|
489
|
-
|
|
490
|
-
return baseLayout(`
|
|
491
|
-
${heading('Resumo Semanal')}
|
|
492
|
-
${subtext(`Ola ${userName}, aqui esta o seu resumo da semana.`)}
|
|
493
|
-
|
|
494
|
-
${divider()}
|
|
495
|
-
|
|
496
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.4);text-transform:uppercase;letter-spacing:0.8px;margin:0 0 12px;">${weekLabel}</p>
|
|
497
|
-
|
|
498
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
|
499
|
-
<tr>
|
|
500
|
-
${metricCard('Concluidas', tasksCompleted, '#10B981')}
|
|
501
|
-
${metricCard('Criadas', tasksCreated, '#A78BFA')}
|
|
502
|
-
${metricCard('Projectos', activeProjects, '#F59E0B')}
|
|
503
|
-
</tr>
|
|
504
|
-
</table>
|
|
505
|
-
|
|
506
|
-
${topProject ? `
|
|
507
|
-
${divider()}
|
|
508
|
-
<p style="font-size:12px;color:rgba(232,229,240,0.5);margin:0;">Projecto mais activo: <strong style="color:#FFFFFF;">${topProject}</strong></p>
|
|
509
|
-
` : ''}
|
|
510
|
-
|
|
511
|
-
${primaryButton('Ver Dashboard', dashboardUrl)}
|
|
512
|
-
`, `Resumo semanal: ${tasksCompleted} tarefas concluidas.`);
|
|
513
|
-
};
|
|
514
|
-
|
|
515
|
-
// ─── Template Registry ──────────────────────
|
|
516
|
-
|
|
517
|
-
export const emailTemplates: Record<EmailTemplateName, TemplateFunction> = {
|
|
518
|
-
'report-ready': reportReady,
|
|
519
|
-
'welcome': welcome,
|
|
520
|
-
'password-reset': passwordReset,
|
|
521
|
-
'workspace-invite': workspaceInvite,
|
|
522
|
-
'sprint-digest': sprintDigest,
|
|
523
|
-
'task-assigned': taskAssigned,
|
|
524
|
-
'task-mentioned': taskMentioned,
|
|
525
|
-
'project-update': projectUpdate,
|
|
526
|
-
'weekly-digest': weeklyDigest,
|
|
527
|
-
};
|
package/tsconfig.json
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "commonjs",
|
|
5
|
-
"lib": ["ES2022"],
|
|
6
|
-
"outDir": "./dist",
|
|
7
|
-
"rootDir": "./src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"declaration": true,
|
|
14
|
-
"declarationMap": true,
|
|
15
|
-
"sourceMap": true
|
|
16
|
-
},
|
|
17
|
-
"include": ["src/**/*"],
|
|
18
|
-
"exclude": ["node_modules", "dist"]
|
|
19
|
-
}
|