Auspicious14 commited on
Commit
06b1815
·
1 Parent(s): 56bc31d

feat(email): replace nodemailer with Resend for email delivery

Browse files

Remove complex SMTP configuration and IPv4 workarounds by switching to the
Resend email API. This simplifies the email service, reduces dependencies,
and eliminates DNS-related connection issues. The global IPv4-first DNS
setting is no longer needed and has been removed from main.ts.

package-lock.json CHANGED
@@ -31,6 +31,7 @@
31
  "passport": "^0.7.0",
32
  "passport-jwt": "^4.0.1",
33
  "pg": "^8.16.3",
 
34
  "swagger-ui-express": "^5.0.1"
35
  },
36
  "devDependencies": {
@@ -1487,6 +1488,12 @@
1487
  "hasInstallScript": true,
1488
  "license": "Apache-2.0"
1489
  },
 
 
 
 
 
 
1490
  "node_modules/@standard-schema/spec": {
1491
  "version": "1.1.0",
1492
  "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -3493,6 +3500,12 @@
3493
  "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
3494
  "license": "MIT"
3495
  },
 
 
 
 
 
 
3496
  "node_modules/fast-uri": {
3497
  "version": "3.1.0",
3498
  "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -5134,6 +5147,12 @@
5134
  "node": ">=4"
5135
  }
5136
  },
 
 
 
 
 
 
5137
  "node_modules/postgres": {
5138
  "version": "3.4.7",
5139
  "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
@@ -5471,6 +5490,27 @@
5471
  "node": ">=0.10.0"
5472
  }
5473
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5474
  "node_modules/resolve-from": {
5475
  "version": "4.0.0",
5476
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5907,6 +5947,16 @@
5907
  "node": ">= 0.6"
5908
  }
5909
  },
 
 
 
 
 
 
 
 
 
 
5910
  "node_modules/statuses": {
5911
  "version": "2.0.2",
5912
  "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -6016,6 +6066,16 @@
6016
  "node": ">=8"
6017
  }
6018
  },
 
 
 
 
 
 
 
 
 
 
6019
  "node_modules/swagger-ui-dist": {
6020
  "version": "5.31.0",
6021
  "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz",
@@ -6513,6 +6573,19 @@
6513
  "node": ">= 0.4.0"
6514
  }
6515
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
6516
  "node_modules/v8-compile-cache-lib": {
6517
  "version": "3.0.1",
6518
  "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
 
31
  "passport": "^0.7.0",
32
  "passport-jwt": "^4.0.1",
33
  "pg": "^8.16.3",
34
+ "resend": "^6.12.0",
35
  "swagger-ui-express": "^5.0.1"
36
  },
37
  "devDependencies": {
 
1488
  "hasInstallScript": true,
1489
  "license": "Apache-2.0"
1490
  },
1491
+ "node_modules/@stablelib/base64": {
1492
+ "version": "1.0.1",
1493
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
1494
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1495
+ "license": "MIT"
1496
+ },
1497
  "node_modules/@standard-schema/spec": {
1498
  "version": "1.1.0",
1499
  "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
 
3500
  "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
3501
  "license": "MIT"
3502
  },
3503
+ "node_modules/fast-sha256": {
3504
+ "version": "1.3.0",
3505
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
3506
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
3507
+ "license": "Unlicense"
3508
+ },
3509
  "node_modules/fast-uri": {
3510
  "version": "3.1.0",
3511
  "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
 
5147
  "node": ">=4"
5148
  }
5149
  },
5150
+ "node_modules/postal-mime": {
5151
+ "version": "2.7.4",
5152
+ "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
5153
+ "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
5154
+ "license": "MIT-0"
5155
+ },
5156
  "node_modules/postgres": {
5157
  "version": "3.4.7",
5158
  "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
 
5490
  "node": ">=0.10.0"
5491
  }
5492
  },
5493
+ "node_modules/resend": {
5494
+ "version": "6.12.0",
5495
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.0.tgz",
5496
+ "integrity": "sha512-CaxEvX1z+/MGbgnhsM/bvmkbnZd1v1sEXELAjBNSDBQNMaB7MgqOyrBgI27CYikEgdaDnBrXghAXYWTBs/h5Bw==",
5497
+ "license": "MIT",
5498
+ "dependencies": {
5499
+ "postal-mime": "2.7.4",
5500
+ "svix": "1.90.0"
5501
+ },
5502
+ "engines": {
5503
+ "node": ">=20"
5504
+ },
5505
+ "peerDependencies": {
5506
+ "@react-email/render": "*"
5507
+ },
5508
+ "peerDependenciesMeta": {
5509
+ "@react-email/render": {
5510
+ "optional": true
5511
+ }
5512
+ }
5513
+ },
5514
  "node_modules/resolve-from": {
5515
  "version": "4.0.0",
5516
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
 
5947
  "node": ">= 0.6"
5948
  }
5949
  },
5950
+ "node_modules/standardwebhooks": {
5951
+ "version": "1.0.0",
5952
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
5953
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
5954
+ "license": "MIT",
5955
+ "dependencies": {
5956
+ "@stablelib/base64": "^1.0.0",
5957
+ "fast-sha256": "^1.3.0"
5958
+ }
5959
+ },
5960
  "node_modules/statuses": {
5961
  "version": "2.0.2",
5962
  "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
 
6066
  "node": ">=8"
6067
  }
6068
  },
6069
+ "node_modules/svix": {
6070
+ "version": "1.90.0",
6071
+ "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
6072
+ "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
6073
+ "license": "MIT",
6074
+ "dependencies": {
6075
+ "standardwebhooks": "1.0.0",
6076
+ "uuid": "^10.0.0"
6077
+ }
6078
+ },
6079
  "node_modules/swagger-ui-dist": {
6080
  "version": "5.31.0",
6081
  "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz",
 
6573
  "node": ">= 0.4.0"
6574
  }
6575
  },
6576
+ "node_modules/uuid": {
6577
+ "version": "10.0.0",
6578
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
6579
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
6580
+ "funding": [
6581
+ "https://github.com/sponsors/broofa",
6582
+ "https://github.com/sponsors/ctavan"
6583
+ ],
6584
+ "license": "MIT",
6585
+ "bin": {
6586
+ "uuid": "dist/bin/uuid"
6587
+ }
6588
+ },
6589
  "node_modules/v8-compile-cache-lib": {
6590
  "version": "3.0.1",
6591
  "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
package.json CHANGED
@@ -70,6 +70,7 @@
70
  "passport": "^0.7.0",
71
  "passport-jwt": "^4.0.1",
72
  "pg": "^8.16.3",
 
73
  "swagger-ui-express": "^5.0.1"
74
  }
75
  }
 
70
  "passport": "^0.7.0",
71
  "passport-jwt": "^4.0.1",
72
  "pg": "^8.16.3",
73
+ "resend": "^6.12.0",
74
  "swagger-ui-express": "^5.0.1"
75
  }
76
  }
src/main.ts CHANGED
@@ -1,9 +1,3 @@
1
- // Force IPv4-first DNS globally — must be the very first import to prevent
2
- // nodemailer (and any other net module) from attempting IPv6 connections on
3
- // hosts that don't support it (e.g. smtp.gmail.com port 587).
4
- import * as dns from "dns";
5
- dns.setDefaultResultOrder("ipv4first");
6
-
7
  import { NestFactory } from "@nestjs/core";
8
  import { AppModule } from "./app.module";
9
  import { setupGlobalMiddleware } from "./setup";
 
 
 
 
 
 
 
1
  import { NestFactory } from "@nestjs/core";
2
  import { AppModule } from "./app.module";
3
  import { setupGlobalMiddleware } from "./setup";
src/notifications/email.service.ts CHANGED
@@ -1,48 +1,24 @@
1
  import { Injectable, Logger } from "@nestjs/common";
2
  import { ConfigService } from "@nestjs/config";
3
- import * as nodemailer from "nodemailer";
4
- import * as dns from "dns";
5
-
6
- // Force all DNS lookups in this process to prefer IPv4
7
- dns.setDefaultResultOrder("ipv4first");
8
 
9
  @Injectable()
10
  export class EmailService {
11
  private readonly logger = new Logger(EmailService.name);
12
- private transporter: nodemailer.Transporter;
 
13
 
14
  constructor(private readonly configService: ConfigService) {
15
- const smtpHost =
16
- this.configService.get<string>("SMTP_HOST") || "smtp.gmail.com";
17
- const smtpPort = parseInt(
18
- this.configService.get<string>("SMTP_PORT") || "587",
19
- 10,
20
- );
21
- const smtpSecure = this.configService.get<string>("SMTP_SECURE") === "true";
22
 
23
- this.transporter = nodemailer.createTransport({
24
- host: smtpHost,
25
- port: smtpPort,
26
- secure: smtpSecure,
27
- // Explicitly force IPv4 — prevents nodemailer falling back to IPv6 after a
28
- // timeout on the IPv4 address when the host resolves to both record types.
29
- family: 4,
30
- pool: false,
31
- connectionTimeout: 15000,
32
- greetingTimeout: 15000,
33
- socketTimeout: 20000,
34
- debug: true,
35
- logger: true,
36
- auth: {
37
- user: this.configService.get<string>("SMTP_USER"),
38
- pass: this.configService.get<string>("SMTP_PASS"),
39
- },
40
- tls: {
41
- // Must match the real hostname for SNI even if we resolve the IP manually
42
- servername: smtpHost,
43
- rejectUnauthorized: false,
44
- },
45
- } as any);
46
  }
47
 
48
  async sendEmail(
@@ -51,36 +27,28 @@ export class EmailService {
51
  text: string,
52
  html?: string,
53
  ): Promise<boolean> {
54
- const from = this.configService.get<string>("SMTP_FROM");
55
- const maxAttempts = 2;
56
-
57
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
58
- try {
59
- const info = await this.transporter.sendMail({
60
- from,
61
- to,
62
- subject,
63
- text,
64
- html: html || text,
65
- });
66
-
67
- this.logger.log(
68
- `Email sent successfully (attempt ${attempt}): ${info.messageId} to ${to}`,
69
- );
70
- return true;
71
- } catch (error: any) {
72
- this.logger.error(
73
- `Failed to send email to ${to} (attempt ${attempt}/${maxAttempts}): ${error.message}`,
74
- attempt === maxAttempts ? error.stack : undefined,
75
- );
76
-
77
- if (attempt < maxAttempts) {
78
- // Wait 2 s before retrying
79
- await new Promise((resolve) => setTimeout(resolve, 2000));
80
- }
81
  }
82
- }
83
 
84
- return false;
 
 
 
 
 
 
 
 
85
  }
86
  }
 
1
  import { Injectable, Logger } from "@nestjs/common";
2
  import { ConfigService } from "@nestjs/config";
3
+ import { Resend } from "resend";
 
 
 
 
4
 
5
  @Injectable()
6
  export class EmailService {
7
  private readonly logger = new Logger(EmailService.name);
8
+ private readonly resend: Resend;
9
+ private readonly fromAddress: string;
10
 
11
  constructor(private readonly configService: ConfigService) {
12
+ const apiKey = this.configService.get<string>("RESEND_API_KEY");
13
+
14
+ if (!apiKey) {
15
+ this.logger.error("RESEND_API_KEY is not set. Emails will not be sent.");
16
+ }
 
 
17
 
18
+ this.resend = new Resend(apiKey);
19
+ this.fromAddress =
20
+ this.configService.get<string>("SMTP_FROM") ||
21
+ "MaternAlert <onboarding@resend.dev>";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
 
24
  async sendEmail(
 
27
  text: string,
28
  html?: string,
29
  ): Promise<boolean> {
30
+ try {
31
+ const { data, error } = await this.resend.emails.send({
32
+ from: this.fromAddress,
33
+ to,
34
+ subject,
35
+ text,
36
+ html: html || text,
37
+ });
38
+
39
+ if (error) {
40
+ this.logger.error(`Failed to send email to ${to}: ${error.message}`);
41
+ return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
 
43
 
44
+ this.logger.log(`Email sent successfully: ${data?.id} to ${to}`);
45
+ return true;
46
+ } catch (error: any) {
47
+ this.logger.error(
48
+ `Unexpected error sending email to ${to}: ${error.message}`,
49
+ error.stack,
50
+ );
51
+ return false;
52
+ }
53
  }
54
  }