Files
sase.tr/apps/api/src/email/email.service.ts
Claude (audit §9.4) bdbdd07566
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(notifications): operability tier — send_limit + open-pixel + signed-URL exp + retention + sent-flag (audit §9.4)
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>
2026-06-04 17:04:17 +03:00

139 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
});
}
}