Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Lands the §9.4 operability tier of postal/mailAudit.md as one PR on top of the §9.3 stack (PR #101). Seven items, all independent of each other but sharing the same notifications surface. #15 Postal send_limit fuse — set per-hour cap (already applied LIVE in DB: servers.send_limit = 500). A runaway loop now hits Postal's own throttle long before flooding recipient ISPs into a rate-limit penalty. #16 noreply@sase.tr decommission — change default fromAddress in both email.service.ts and config/configuration.ts to destek@sase.tr. `noreply@` had no inbound route so user replies bounced; `destek@` lands in the SnappyMail destek inbox. Overridable via POSTAL_FROM_ADDRESS env per workflow that genuinely shouldn't accept replies. #21 Welcome CTA fallback — flip the {{else}} branch in novu-welcome.html from https://sase.tr to https://sase.tr/dashboard (the actual onboarding entry, not the marketing page). Already pushed live to Novu Mongo too. #17 Open-pixel embed — new buildTrackPixelUrl() in novu.ts; injected trackPixel payload into welcome/trial-ending/win-back/referral×3/ payment×2 NovuService methods + lifecycle-email.processor. Auth flows (email-verification, password-reset) deliberately skip the pixel. Templates updated with {{#if trackPixel}}<img ...>{{/if}} just before the footer; 8 templates touched, 2 (auth) skipped. Novu Mongo updated. #18 Signed-URL exp / replay-resistance — track.sase.tr Worker /c endpoint now expects `e=<unix-ms>` + `s=HMAC(MID|TARGET|EXP)`. Expired signatures return 410. Legacy signatures (no `e=`) still accepted while in-flight mail with old links drains; remove that branch ~30 days post-deploy. buildTrackedUrl() now mints exp=now+30d. #19 D1 retention cron — Cloudflare Cron Trigger added to mailtrack worker (`17 4 * * *` UTC, after Europe/Istanbul cron settles). `scheduled` handler DELETEs events older than RETENTION_DAYS (default 90). Both code and the cron schedule are LIVE on the production worker. #20 Lifecycle sent-flag idempotency — new `lifecycle_email_sent` table (migration 0012) keyed (user_id, workflow). Replaces the 1-day endDate window's at-most-once trick that lost cohorts on skipped days. Cron now LEFT JOINs and writes the row immediately after each successful trigger. Historical seed in scripts/backfill-lifecycle-sent.sql (19 trial-ending + 5 win-back users — generated from postal-server-1.messages) so the first post-deploy cron doesn't re-send to users we already mailed. ## Live infrastructure (deploy-independent) - Postal MariaDB: `UPDATE servers SET send_limit = 500`. - Cloudflare Worker mailtrack redeployed with new /c logic + scheduled handler. - Cloudflare Worker: cron `17 4 * * *` registered on production env. - Cloudflare Worker: RETENTION_DAYS=90 plain_text binding. - Novu Mongo: 16 messagetemplates updated with pixel + Welcome /dashboard. ## Companion deploy steps post-merge 1. `pnpm db:generate` to refresh drizzle snapshots for 0011 + 0012. 2. Run `scripts/backfill-lifecycle-sent.sql` against prod + dev BEFORE the first cron tick post-deploy. 3. Apply host-side novu-patches/apply-headers-patch.sh again if Novu container rolled (idempotent). ## Verification curl /c?…e=<future>… ⇒ 302 new-style signature accepted curl /c?…(no e)… ⇒ 302 legacy signature still accepted (drain) curl /c?…e=<past>… ⇒ 410 expired signature rejected curl /c?…s=bad… ⇒ 403 bad signature rejected CF API schedules ⇒ `17 4 * * *` live on mailtrack worker. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
||
import { ConfigService } from "@nestjs/config";
|
||
|
||
export interface SendEmailOptions {
|
||
to: string;
|
||
subject: string;
|
||
html: string;
|
||
text?: string;
|
||
tag?: string;
|
||
replyTo?: string;
|
||
}
|
||
|
||
interface PostalApiResponse {
|
||
status: string;
|
||
data?: { message_id: string };
|
||
}
|
||
|
||
@Injectable()
|
||
export class EmailService {
|
||
private readonly logger = new Logger(EmailService.name);
|
||
private readonly postalApiUrl: string | undefined;
|
||
private readonly postalApiKey: string | undefined;
|
||
private readonly fromAddress: string;
|
||
private readonly fromName: string;
|
||
|
||
constructor(private configService: ConfigService) {
|
||
this.postalApiUrl = configService.get<string>("email.postalApiUrl");
|
||
this.postalApiKey = configService.get<string>("email.postalApiKey");
|
||
// `destek@sase.tr` is monitored — replies go to the SnappyMail destek
|
||
// inbox so users who hit "reply" actually reach someone. Older default
|
||
// (`noreply@sase.tr`) had no inbound route and dropped replies.
|
||
// mailAudit.md §9.4 #16.
|
||
this.fromAddress = configService.get<string>("email.fromAddress") || "destek@sase.tr";
|
||
this.fromName = configService.get<string>("email.fromName") || "Sase.tr";
|
||
}
|
||
|
||
private get isConfigured(): boolean {
|
||
return !!(this.postalApiUrl && this.postalApiKey);
|
||
}
|
||
|
||
async send(options: SendEmailOptions): Promise<void> {
|
||
if (!this.postalApiUrl || !this.postalApiKey) {
|
||
this.logger.log(`[DEV EMAIL] To: ${options.to}`);
|
||
this.logger.log(`[DEV EMAIL] Subject: ${options.subject}`);
|
||
this.logger.log(`[DEV EMAIL] Body: ${options.text || options.html.substring(0, 200)}`);
|
||
return;
|
||
}
|
||
|
||
const payload = {
|
||
to: [options.to],
|
||
from: `${this.fromName} <${this.fromAddress}>`,
|
||
subject: options.subject,
|
||
html_body: options.html,
|
||
...(options.text && { plain_body: options.text }),
|
||
...(options.tag && { tag: options.tag }),
|
||
...(options.replyTo && { reply_to: options.replyTo }),
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(`${this.postalApiUrl}/api/v1/send/message`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Server-API-Key": this.postalApiKey,
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
const result = (await response.json()) as PostalApiResponse;
|
||
|
||
if (result.status !== "success") {
|
||
this.logger.error(`Postal API error: ${JSON.stringify(result)}`);
|
||
throw new Error(`Email sending failed: ${result.status}`);
|
||
}
|
||
|
||
this.logger.log(`Email sent to ${options.to} [${options.tag || "no-tag"}]`);
|
||
} catch (error) {
|
||
this.logger.error(`Failed to send email to ${options.to}: ${error}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async sendPasswordReset(to: string, resetUrl: string): Promise<void> {
|
||
await this.send({
|
||
to,
|
||
subject: "Şifre Sıfırlama - Sase.tr",
|
||
html: `
|
||
<h2>Şifre Sıfırlama</h2>
|
||
<p>Şifrenizi sıfırlamak için aşağıdaki bağlantıya tıklayın:</p>
|
||
<a href="${resetUrl}">${resetUrl}</a>
|
||
<p>Bu bağlantı 1 saat geçerlidir.</p>
|
||
`,
|
||
text: `Şifrenizi sıfırlamak için bu bağlantıyı kullanın: ${resetUrl}`,
|
||
tag: "password-reset",
|
||
});
|
||
}
|
||
|
||
async sendWelcome(to: string, name: string): Promise<void> {
|
||
await this.send({
|
||
to,
|
||
subject: "Hoş Geldiniz - Sase.tr",
|
||
html: `
|
||
<h2>Hoş Geldiniz, ${name}!</h2>
|
||
<p>Sase.tr'ye kaydınız başarılı. Aracınızın VIN numarasıyla yedek parça aramasına başlayabilirsiniz.</p>
|
||
`,
|
||
text: `Hoş Geldiniz ${name}! Sase.tr'ye kaydınız başarılı.`,
|
||
tag: "welcome",
|
||
});
|
||
}
|
||
|
||
async sendPaymentConfirmation(to: string, amount: string): Promise<void> {
|
||
await this.send({
|
||
to,
|
||
subject: "Ödeme Onayı - Sase.tr",
|
||
html: `
|
||
<h2>Ödeme Onayı</h2>
|
||
<p>${amount} tutarındaki ödemeniz onaylanmıştır. Aboneliğiniz aktif edilmiştir.</p>
|
||
`,
|
||
text: `${amount} tutarındaki ödemeniz onaylanmıştır.`,
|
||
tag: "payment-confirmation",
|
||
});
|
||
}
|
||
|
||
async sendEmailVerification(to: string, verificationUrl: string): Promise<void> {
|
||
await this.send({
|
||
to,
|
||
subject: "E-posta Doğrulama - Sase.tr",
|
||
html: `
|
||
<h2>E-posta Doğrulama</h2>
|
||
<p>E-posta adresinizi doğrulamak için aşağıdaki bağlantıya tıklayın:</p>
|
||
<a href="${verificationUrl}">${verificationUrl}</a>
|
||
<p>Bu bağlantı 24 saat geçerlidir.</p>
|
||
`,
|
||
text: `E-posta adresinizi doğrulamak için bu bağlantıyı kullanın: ${verificationUrl}`,
|
||
tag: "email-verification",
|
||
});
|
||
}
|
||
}
|