Skip to content

Commit

Permalink
88 transactional emails (#90)
Browse files Browse the repository at this point in the history
* add user signup email

* login emails

* slack invite, and email team
  • Loading branch information
jaspermayone authored Mar 7, 2025
1 parent ae1229e commit c67bfb0
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 14 deletions.
3 changes: 3 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"node-statsd": "^0.1.1",
"postmark": "^4.0.5",
"request-ip": "^3.3.0",
"response-time": "^2.3.3",
"ts-node": "^10.9.2",
Expand Down Expand Up @@ -577,6 +578,8 @@

"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ=="],

"postmark": ["[email protected]", "", { "dependencies": { "axios": "^1.7.4" } }, "sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ=="],

"prisma": ["[email protected]", "", { "dependencies": { "@prisma/engines": "6.4.1", "esbuild": ">=0.12 <1", "esbuild-register": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-q2uJkgXnua/jj66mk6P9bX/zgYJFI/jn4Yp0aS6SPRrjH/n6VyOV7RDe1vHD0DX8Aanx4MvgmUPPoYnR6MJnPg=="],

"proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"node-statsd": "^0.1.1",
"postmark": "^4.0.5",
"request-ip": "^3.3.0",
"response-time": "^2.3.3",
"ts-node": "^10.9.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "LoginAttempt" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"ipAddress" TEXT NOT NULL,
"userAgent" TEXT,
"successful" BOOLEAN NOT NULL DEFAULT true,
"ipInfo" TEXT,
"country" TEXT,
"city" TEXT,
"region" TEXT,
"isp" TEXT,
"organization" TEXT,
"deviceType" TEXT,
"browser" TEXT,
"loginMethod" TEXT DEFAULT 'password',

CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "LoginAttempt_userId_idx" ON "LoginAttempt"("userId");

-- CreateIndex
CREATE INDEX "LoginAttempt_ipAddress_idx" ON "LoginAttempt"("ipAddress");

-- CreateIndex
CREATE INDEX "LoginAttempt_createdAt_idx" ON "LoginAttempt"("createdAt");

-- AddForeignKey
ALTER TABLE "LoginAttempt" ADD CONSTRAINT "LoginAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
34 changes: 34 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ model User {
emailsReported Email[]
reportedDomains DomainReport[] @relation("reporter")
reviewedDomains DomainReport[] @relation("reviewer")
loginAttempts LoginAttempt[]
}

model LoginAttempt {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
// User relationship
userId Int
user User @relation(fields: [userId], references: [id])
// Login details
ipAddress String
userAgent String?
successful Boolean @default(true)
// Detailed IP information (stored as JSON)
ipInfo String? // JSON string containing all IP geolocation data
// Extracted key fields for easier querying
country String?
city String?
region String?
isp String?
organization String?
// Additional metadata
deviceType String? // Mobile, Desktop, Tablet, etc.
browser String? // Chrome, Firefox, Safari, etc.
loginMethod String? @default("password") // password, oauth, token, etc.
@@index([userId])
@@index([ipAddress])
@@index([createdAt])
}

model Domain {
Expand Down
72 changes: 72 additions & 0 deletions src/func/slackInvite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export async function inviteToSlack(email: string) {
const xoxc = process.env.SLACK_BROWSER_TOKEN!;
const xoxd = process.env.SLACK_COOKIE!;

const payload = `------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="token"
${xoxc}
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="invites"
[{"email":"${email}","type":"regular","mode":"manual"}]
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="team_id"
T07J9QT2P8R
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="restricted"
false
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="ultra_restricted"
false
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="campaign"
team_menu
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="_x_reason"
submit-invite-to-workspace-invites
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="_x_mode"
online
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="_x_sonic"
true
------WebKitFormBoundaryIk2wxHLsyrGnUA6A
Content-Disposition: form-data; name="_x_app_name"
client
------WebKitFormBoundaryIk2wxHLsyrGnUA6A--`;

let result;

try {
const data = await fetch(
"https://phishdirectory.slack.com/api/users.admin.inviteBulk",
{
headers: {
"content-type":
"multipart/form-data; boundary=----WebKitFormBoundaryIk2wxHLsyrGnUA6A",
},
referrerPolicy: "no-referrer",
body: payload,
method: "POST",
mode: "cors",
credentials: "include",
}
);

result = data.ok;
} catch (e) {
console.error(e);
result = false;
}

return result;
}
162 changes: 148 additions & 14 deletions src/routes/user.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import bcrypt from "bcrypt";
import express from "express";

import { inviteToSlack } from "../func/slackInvite";
import { logRequest } from "../middleware/logRequest";
import {
authenticateToken,
generateAccessToken,
getUserInfo,
} from "../utils/jwt";
import postmark from "../utils/postmark";
import { prisma } from "../utils/prisma";
import { userNeedsExtendedData } from "../utils/userNeedsExtendedData";

Expand Down Expand Up @@ -39,9 +41,7 @@ let saltRounds = 10;
*/
router.post("/signup", async (req, res) => {
// metrics.increment("endpoint.user.signup");

const body = req.body;

const { name, email, password } = body;

if (!name || !email || !password) {
Expand All @@ -66,19 +66,52 @@ router.post("/signup", async (req, res) => {
const salt = bcrypt.genSaltSync(saltRounds);
let passHash = await bcrypt.hash(password, salt);

// Create the user
const newUser = await prisma.user.create({
data: {
name: name,
email: email,
password: passHash,
},
});
try {
// Create the user
const newUser = await prisma.user.create({
data: {
name: name,
email: email,
password: passHash,
},
});

res.status(200).json({
message: "User created successfully, please login.",
uuid: newUser.uuid,
});
// Send welcome email after user is created
await postmark.sendEmailWithTemplate({
From: "[email protected]",
To: newUser.email,
TemplateAlias: "welcome",
TemplateModel: {
product_url: "https://api.phish.directory",
product_name: "Phish Directory API",
name: newUser.name,
email: newUser.email,
company_name: "Phish Directory",
company_address: "36 Old Quarry Rd, Fayston, VT 05673",
},
});

postmark.sendEmail({
From: "[email protected]",
To: "[email protected]",
Subject: "New User Signup",
// email the team and provide name, and email of the new user,
HtmlBody: `<html><body><h1>New User Signup</h1><p>Name: ${newUser.name}</p><p>Email: ${newUser.email}</p></body></html>`,
});

await inviteToSlack(newUser.email);

// Send success response with the user's uuid
res.status(200).json({
message: "User created successfully, please login.",
uuid: newUser.uuid,
});
} catch (error: any) {
res.status(500).json({
message: "Error creating user",
error: error.message,
});
}
});

/**
Expand Down Expand Up @@ -114,6 +147,61 @@ router.post("/login", async (req, res) => {
return;
}

// Get IP address from the request
const ipAddress =
req.headers["x-forwarded-for"] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.ip ||
"0.0.0.0";

// Get detailed IP information from ipgeolocation.io
let ipInfo = {
ip: ipAddress,
timestamp: new Date().toISOString(),
userAgent: req.headers["user-agent"] || "Unknown",
country: null,
city: null,
region: null,
location: null,
isp: null,
organization: null,
};

try {
const API_KEY = process.env.IPGEOLOCATION_API_KEY; // Store your API key in environment variables
const geoResponse = await fetch(
`https://api.ipgeolocation.io/ipgeo?apiKey=${API_KEY}&ip=${ipAddress}`
);

if (geoResponse.ok) {
const geoData = await geoResponse.json();

// Update ipInfo with data from the API
ipInfo = {
...ipInfo,
country: geoData.country_name,
countryCode: geoData.country_code2,
city: geoData.city,
region: geoData.state_prov,
regionCode: geoData.state_code,
zipcode: geoData.zipcode,
// @ts-expect-error
location: {
lat: geoData.latitude,
lng: geoData.longitude,
},
isp: geoData.isp,
organization: geoData.organization,
timezone: geoData.time_zone?.name,
currency: geoData.currency?.code,
};
}
} catch (error) {
console.error("Failed to fetch IP geolocation data:", error);
// Continue with basic IP info if geolocation lookup fails
}

const user = await prisma.user.findUnique({
where: {
email: email,
Expand All @@ -138,10 +226,56 @@ router.post("/login", async (req, res) => {
);
}

// Store login attempt with IP information
try {
await prisma.loginAttempt.create({
data: {
userId: user.id,
ipAddress: ipAddress as string,
userAgent: ipInfo.userAgent,
successful: true,
ipInfo: JSON.stringify(ipInfo),
},
});
} catch (error) {
console.error("Failed to log login attempt:", error);
// Continue with login process even if logging fails
}

let token = await generateAccessToken(user);

let jsonresponsebody = {};

// Update the email to include comprehensive IP address information
await postmark.sendEmail({
From: "[email protected]",
To: user.email,
Subject: "Phish Directory API Login",
HtmlBody: `<html><body>
<h1>Hello ${user.name}</h1>
<p>You have successfully logged in to the Phish Directory API.</p>
<p>Login details:</p>
<ul>
<li>Time: ${ipInfo.timestamp}</li>
<li>IP Address: ${ipInfo.ip}</li>
<li>Device: ${ipInfo.userAgent}</li>
${
ipInfo.city
? // @ts-expect-error
`<li>Location: ${ipInfo.city}, ${ipInfo.region} ${ipInfo.countryCode}</li>`
: ""
}
${
ipInfo.organization
? `<li>Organization: ${ipInfo.organization}</li>`
: ""
}
${ipInfo.isp ? `<li>Internet Provider: ${ipInfo.isp}</li>` : ""}
</ul>
<p>If this wasn't you, please contact <a href="mailto:[email protected]">[email protected]</a> immediately AND change your password.</p>
</body></html>`,
});

if (useExteded) {
jsonresponsebody = {
token: token,
Expand Down
5 changes: 5 additions & 0 deletions src/utils/postmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import postmark from "postmark";

const client = new postmark.ServerClient(process.env.POSTMARK_API_KEY!);

export default client as postmark.ServerClient;

0 comments on commit c67bfb0

Please sign in to comment.