Spaces:
Running
Running
Commit ·
06b1815
1
Parent(s): 56bc31d
feat(email): replace nodemailer with Resend for email delivery
Browse filesRemove 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 +73 -0
- package.json +1 -0
- src/main.ts +0 -6
- src/notifications/email.service.ts +33 -65
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
|
| 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
|
|
|
|
| 13 |
|
| 14 |
constructor(private readonly configService: ConfigService) {
|
| 15 |
-
const
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
this.
|
| 19 |
-
|
| 20 |
-
);
|
| 21 |
-
const smtpSecure = this.configService.get<string>("SMTP_SECURE") === "true";
|
| 22 |
|
| 23 |
-
this.
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|