Tech Interview Preparation Module 11 – Safety, ethics and your rights
Spotting job scams and fake offers
Spot job scams and fake offers by their signs, from fee demands to look-alike email domains, verify the role before you share documents, and report fast.
What you will learn
- Recognise a job scam by its signs, starting with any request for money
- Sort a message into scam, verify first or likely genuine, and name its red flags
- Check a sender's domain against the employer's own, even when DMARC passes
- Verify a role and an overseas offer before you share any documents
- Report a scam through the right channel, and act at once if you paid
What you’ll learn
What you will learn
- Recognise a job scam by its signs, starting with any request for money
- Sort a message into scam, verify first or likely genuine, and name its red flags
- Check a sender's domain against the employer's own, even when DMARC passes
- Verify a role and an overseas offer before you share any documents
- Report a scam through the right channel, and act at once if you paid
On this page
A message offers you paid online work, liking videos or rating products from your phone, and a week later asks for a deposit before you can withdraw what you have earned. The US Federal Trade Commission received about 20,000 reports of this kind of scam in six months, four times as many as in the whole year before. Job scams work because they copy the first steps of real hiring: a recruiter, an interview, an offer letter with a logo.
One rule catches most of them: an honest employer never asks you for money. This lesson adds the other signs to look for, a check of the sender’s domain that you can run, and where to report a scam in India, the US and elsewhere.
Not affiliated
MySmartCoPilot is independent of Wipro and of every other organisation named here; each rule quoted comes from the organisation’s own pages, listed under References. Tessera Labs and HireWell are invented for this lesson, and their domains end in .example, a name reserved for examples.
Any request for money marks the offer as a scam
Rohan has applied to a dozen companies when a message says he is selected for a backend role and must pay a refundable deposit of 4,999 for his laptop before he joins. The deposit is the whole scam. The FTC’s rule is short: honest employers, the federal government included, never ask you to pay to get a job, and anyone who does is a scammer. Employers say the same of themselves. Wipro says it does not charge any fee at any stage of recruitment, has authorised no agency to collect one, and that fake offers ask for a security deposit with a promise to return it after you join. India’s National Career Service, the government’s job portal, charges no fee at any stage and tells job seekers not to pay fees to employers.
Step through how such an offer unfolds, and notice how late the request for money comes:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Out of the blue. A chat message from an unknown number says your profile is shortlisted for a remote developer role at Tessera Labs, at pay well above the market. You never applied. Unexpected contact about a job you did not seek is the first sign.
- An interview by chat. The interview is five questions typed into the chat. Employers such as Wipro say they never interview over instant messaging apps, and that their hiring includes at least one interview, virtual or in person.
- An offer at once. Minutes later an offer letter arrives as a PDF, with the company's logo and a signature. A logo costs nothing to copy, so the letter proves nothing about who sent it.
- The request for money. To confirm your seat you are asked for a refundable security deposit of 4,999 for a laptop. This is the moment the scam shows itself, because an employer that hires you does not charge you to be hired.
- The payment. If you pay, the money goes to an account the scammer controls. Stopping here costs nothing, because no genuine offer depends on a payment from you.
- The money moves on. The bank sends the money to the account you named. From there it can be moved on quickly, which is why a payment should be reported at once.
- A second fee. A day later comes a new fee, for background verification or onboarding. Someone who paid once is likely to pay again, so the requests continue.
- Silence. Then the number stops answering and the website disappears, as the FTC describes for job scams. Everything you were promised was a way to collect the payments.
The money is asked for last, after a chat, a quick selection and a letter with a logo have built trust, so apply the rule at the moment money is asked for, however real everything before it looked. The fee takes many names: registration, training, a certification, a laptop or a uniform, background verification, visa processing or a refundable deposit. In a task scam: A job scam that pays small sums for simple online tasks, such as liking videos or rating products, then asks for deposits before you may go on or withdraw the balance shown, which is never paid out. it is a deposit you must make to complete the next set of tasks or to withdraw your earnings, and the FTC warns that this money does not come back. In a fake-cheque scam: A scam in which a new employer or buyer sends you a cheque and asks you to send part of the money on; the cheque bounces later, and you may owe your bank the full amount. you deposit a cheque and send part of it on; the cheque bounces, and you may owe the bank the full amount.
Paying because the deposit is refundable
“It is refundable, so it is safe” is the reasoning the scam relies on. A refundable deposit is still a payment to get a job, and the promise to return it is part of the script that Wipro describes. Decline, and keep the message as evidence.
The rule has one narrow exception, for jobs abroad recruited through a registered agent in India, which the section on verifying an offer covers.
Key idea
When a message asks you to pay anything to get or keep a job, stop: honest employers never charge candidates, whatever the fee is called.
Checkpoint
Check: Any request for money marks a scam
Other signs: chat-only hiring, instant offers, tasks and cheques
Many scams have not asked for money yet when you first see them, so the other signs matter. The FTC lists four signs of any scam: scammers pretend to be from an organisation you know, say there is a problem or a prize, pressure you to act at once, and tell you to pay in a specific way. In job scams they look like this:
| The sign | What it looks like | What the source says |
|---|---|---|
| Contact out of the blue | A text or WhatsApp message about online work you never asked for | FTC: real employers never contact you that way |
| A chat-only interview | Questions typed into WhatsApp or Telegram | Wipro: it never interviews over messaging apps |
| An offer with no interview | “You are selected” after a form or a chat | Wipro: its hiring has at least one interview |
| Pay for easy tasks | Commission for liking videos, then deposits | FTC: paying to get paid is a sure sign of a scam |
| A cheque to pass on | Deposit it, keep a share, send the rest | FTC: the cheque is fake and will bounce |
| Pay far above the role | A lot of money for little work, quickly | FTC: work-from-home scams promise this |
| Pressure to decide now | “Confirm today or lose the seat” | FTC: scammers pressure you to act at once |
| Public webmail | An offer from a gmail.com address | Wipro: fake offers come from public email IDs |
Here are three messages, with the signs each one shows:
- “Congratulations! Your profile is shortlisted for Data Entry Associate, work from home, 3,000 a day. Reply YES to join our Telegram group for the interview.” Contact out of the blue, a chat-only interview and pay far above the role: verify before you reply, and expect a fee to follow.
- “Dear candidate, you are selected for Software Engineer Trainee at Tessera Labs. Pay 2,500 for background verification by tonight to keep your seat.” No interview, a deadline and a fee: a scam on the fee alone.
- “Hello, Meghna from the Tessera Labs talent team. Thank you for applying for the backend role, reference 4471. Could we set up a 45-minute video interview this week? The role is on our careers page.” No sign shows, so it is likely genuine, and the sender’s address still deserves the check of the next section.
Checkpoint
Check: The other signs of a job scam
Check the sender’s domain, even when DMARC passes
A scam that copies an employer’s name usually cannot send mail from that employer’s domain, so the address is the next thing to check. The display name, such as “Tessera HR”, is text anyone can type; what counts is the domain after the @. Take the employer’s domain from its own careers site, here tessera.example, and accept a sender only when its domain is exactly that or a subdomain ending in .tessera.example. A look-alike domain: A domain name made to resemble an employer's own, with a word added or a letter changed, such as tessera-careers.example or tesera.example for tessera.example. fails this test: a word added, as in tessera-careers.example, or a letter dropped, as in tesera.example.
Mail services also record whether a message passed DMARC: Domain-based Message Authentication, Reporting and Conformance: an email standard by which a receiving server checks that the domain in a message's From address authorised it. A pass says nothing about who owns that domain.. A pass means that the domain in the From address authorised the message. It says nothing about whether that domain is the employer’s: the DMARC standard says it does not deal with visually similar domain names, so a look-alike passes for itself. Read the result that your own mail service recorded in the Authentication-Results header. The standard for that header warns that its presence does not make its contents true, because a sender can add a forged one. Step through the checks on one message:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The address, not the name. The display name Tessera HR is text that anyone can type. What counts is the address after the @, here tessera-careers.example.
- Is it the employer's domain?. The employer's careers site uses tessera.example. The sender's domain is neither tessera.example nor a subdomain ending in .tessera.example, so the check fails.
- The shortcut for real mail. Had the domain been the employer's, the next question would be only whether DMARC passed, which is the branch on the right.
- Public webmail?. The domain is not a public webmail service. An offer from gmail.com or outlook.com would fail here, because employers send offers from their own domain.
- Close to the employer's name?. The domain contains tessera, the employer's name, with a word added. That makes it a look-alike, made to be read at a glance as the real thing.
- What DMARC says. Your own mail server recorded DMARC pass. A pass only says that tessera-careers.example authorised the message; DMARC does not deal with names that look like another's.
- The verdict. A look-alike domain is a scam whatever DMARC says. The pass made the message look trustworthy, which is why the domain is checked before the result.
The program runs the same checks on five messages, each with the DMARC result its mail server recorded. It compares each domain with tessera.example, flags public webmail, and measures how far a domain’s first label is from the employer’s name as an edit distance: The fewest single-letter changes, insertions or deletions that turn one word into another; tesera is one edit away from tessera., the number of letters you would change, add or remove to turn one into the other. Each tab holds the same program, and all 14 printed the same lines:
Python 3.14 · main.py
OFFICIAL = "tessera.example"
WEBMAIL = ("gmail.com", "outlook.com")
SENDERS = [
("meghna@tessera.example", "pass"),
("hr@tessera-careers.example", "pass"),
("offers@tesera.example", "none"),
("tessera.hiring@gmail.com", "pass"),
("meghna@tessera.example", "fail"),
]
def distance(a: str, b: str) -> int:
d = [[i + j for j in range(len(b) + 1)] for i in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 1):
change = d[i - 1][j - 1] + (a[i - 1] != b[j - 1])
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
return d[len(a)][len(b)]
def assess(domain: str, result: str) -> str:
brand = OFFICIAL.split(".")[0]
edits = distance(domain.split(".")[0], brand)
if domain == OFFICIAL or domain.endswith("." + OFFICIAL):
verdict = "checks out" if result == "pass" else "forged"
return f"the employer's domain: {verdict}"
if domain in WEBMAIL:
return "public webmail: not the employer"
if brand in domain:
return f"look-alike, contains {brand}: scam"
if edits <= 2:
return f"look-alike, edit distance {edits}: scam"
return "another domain: ask the employer"
for sender, result in SENDERS:
print(f"{sender}, DMARC {result}")
print(f" {assess(sender.rpartition('@')[2].lower(), result)}") In Python: (a[i - 1] != b[j - 1]) adds a bool to an int, and True counts as 1, so a mismatch costs one edit. sender.rpartition('@')[2] is the text after the last @.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
const OFFICIAL = "tessera.example";
const WEBMAIL = ["gmail.com", "outlook.com"];
const SENDERS = [
["meghna@tessera.example", "pass"],
["hr@tessera-careers.example", "pass"],
["offers@tesera.example", "none"],
["tessera.hiring@gmail.com", "pass"],
["meghna@tessera.example", "fail"],
];
function distance(a, b) {
const d = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => i + j),
);
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const change = d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, change);
}
}
return d[a.length][b.length];
}
function assess(domain, result) {
const brand = OFFICIAL.split(".")[0];
const edits = distance(domain.split(".")[0], brand);
if (domain === OFFICIAL || domain.endsWith("." + OFFICIAL)) {
return `the employer's domain: ${result === "pass" ? "checks out" : "forged"}`;
}
if (WEBMAIL.includes(domain)) return "public webmail: not the employer";
if (domain.includes(brand)) return `look-alike, contains ${brand}: scam`;
if (edits <= 2) return `look-alike, edit distance ${edits}: scam`;
return "another domain: ask the employer";
}
for (const [sender, result] of SENDERS) {
console.log(`${sender}, DMARC ${result}`);
console.log(` ${assess(sender.slice(sender.lastIndexOf("@") + 1).toLowerCase(), result)}`);
} In JavaScript: Array.from with a mapping function builds each row of the table already filled with i + j, and a[i - 1] reads one character of the label.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
import java.util.Locale;
public class Main {
static final String OFFICIAL = "tessera.example";
static final List<String> WEBMAIL = List.of("gmail.com", "outlook.com");
record Sender(String address, String result) {}
static final List<Sender> SENDERS =
List.of(
new Sender("meghna@tessera.example", "pass"),
new Sender("hr@tessera-careers.example", "pass"),
new Sender("offers@tesera.example", "none"),
new Sender("tessera.hiring@gmail.com", "pass"),
new Sender("meghna@tessera.example", "fail"));
static int distance(String a, String b) {
int[][] d = new int[a.length() + 1][b.length() + 1];
for (int i = 0; i <= a.length(); i++) {
for (int j = 0; j <= b.length(); j++) {
d[i][j] = i + j;
if (i > 0 && j > 0) {
int change = d[i - 1][j - 1] + (a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1);
d[i][j] = Math.min(Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1), change);
}
}
}
return d[a.length()][b.length()];
}
static String assess(String domain, String result) {
String brand = OFFICIAL.split("\\.")[0];
int edits = distance(domain.split("\\.")[0], brand);
if (domain.equals(OFFICIAL) || domain.endsWith("." + OFFICIAL)) {
return "the employer's domain: " + (result.equals("pass") ? "checks out" : "forged");
} else if (WEBMAIL.contains(domain)) {
return "public webmail: not the employer";
} else if (domain.contains(brand)) {
return "look-alike, contains " + brand + ": scam";
} else if (edits <= 2) {
return "look-alike, edit distance " + edits + ": scam";
}
return "another domain: ask the employer";
}
public static void main(String[] args) {
for (Sender s : SENDERS) {
String domain = s.address().substring(s.address().lastIndexOf('@') + 1);
System.out.println(s.address() + ", DMARC " + s.result());
System.out.println(" " + assess(domain.toLowerCase(Locale.ROOT), s.result()));
}
}
} In Java: toLowerCase(Locale.ROOT) lowercases the same way on every device; plain toLowerCase() follows the device's language and turns I into a dotless ı on a Turkish system.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <algorithm>
#include <array>
#include <cctype>
#include <format>
#include <print>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
constexpr std::string_view official = "tessera.example";
constexpr std::array<std::string_view, 2> webmail = {"gmail.com",
"outlook.com"};
constexpr std::array<std::pair<std::string_view, std::string_view>, 5> senders =
{{{"meghna@tessera.example", "pass"},
{"hr@tessera-careers.example", "pass"},
{"offers@tesera.example", "none"},
{"tessera.hiring@gmail.com", "pass"},
{"meghna@tessera.example", "fail"}}};
int distance(std::string_view a, std::string_view b) {
std::vector d(a.size() + 1, std::vector<int>(b.size() + 1));
for (std::size_t i = 0; i <= a.size(); i++) {
for (std::size_t j = 0; j <= b.size(); j++) {
d[i][j] = static_cast<int>(i + j);
if (i > 0 && j > 0) {
const int change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
d[i][j] = std::min({d[i - 1][j] + 1, d[i][j - 1] + 1, change});
}
}
}
return d[a.size()][b.size()];
}
std::string assess(std::string_view domain, std::string_view result) {
const auto brand = official.substr(0, official.find('.'));
const int edits = distance(domain.substr(0, domain.find('.')), brand);
if (domain == official || domain.ends_with(std::format(".{}", official))) {
return std::format("the employer's domain: {}",
result == "pass" ? "checks out" : "forged");
}
if (std::ranges::contains(webmail, domain)) {
return "public webmail: not the employer";
}
if (domain.contains(brand)) {
return std::format("look-alike, contains {}: scam", brand);
}
if (edits <= 2) {
return std::format("look-alike, edit distance {}: scam", edits);
}
return "another domain: ask the employer";
}
int main() {
for (const auto &[address, result] : senders) {
std::string domain{address.substr(address.rfind('@') + 1)};
std::ranges::transform(domain, domain.begin(),
[](unsigned char c) { return std::tolower(c); });
std::println("{}, DMARC {}", address, result);
std::println(" {}", assess(domain, result));
}
} In C++: std::ranges::contains and string_view::contains are new in C++23, and ends_with in C++20. std::tolower is given each character as an unsigned char, as it requires.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
const string Official = "tessera.example";
(string Address, string Result)[] senders =
[
("meghna@tessera.example", "pass"),
("hr@tessera-careers.example", "pass"),
("offers@tesera.example", "none"),
("tessera.hiring@gmail.com", "pass"),
("meghna@tessera.example", "fail"),
];
foreach (var (address, result) in senders)
{
string domain = address[(address.LastIndexOf('@') + 1)..].ToLowerInvariant();
Console.WriteLine($"{address}, DMARC {result}");
Console.WriteLine($" {Assess(domain, result)}");
}
static int Distance(string a, string b)
{
var d = new int[a.Length + 1, b.Length + 1];
for (int i = 0; i <= a.Length; i++)
{
for (int j = 0; j <= b.Length; j++)
{
d[i, j] = i + j;
if (i > 0 && j > 0)
{
int change = d[i - 1, j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), change);
}
}
}
return d[a.Length, b.Length];
}
static string Assess(string domain, string result)
{
string brand = Official.Split('.')[0];
int edits = Distance(domain.Split('.')[0], brand);
return domain switch
{
_ when domain == Official || domain.EndsWith("." + Official, StringComparison.Ordinal) =>
"the employer's domain: " + (result == "pass" ? "checks out" : "forged"),
"gmail.com" or "outlook.com" => "public webmail: not the employer",
_ when domain.Contains(brand, StringComparison.Ordinal) =>
$"look-alike, contains {brand}: scam",
_ when edits <= 2 => $"look-alike, edit distance {edits}: scam",
_ => "another domain: ask the employer",
};
} In C#: The webmail domains are a pattern in the switch expression, "gmail.com" or "outlook.com": a C# pattern takes constants, and an array cannot be one.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <ctype.h>
#include <stdio.h>
#include <string.h>
static const char OFFICIAL[] = "tessera.example";
static const char *const WEBMAIL[] = {"gmail.com", "outlook.com"};
static const char *const SENDERS[][2] = {
{"meghna@tessera.example", "pass"}, {"hr@tessera-careers.example", "pass"},
{"offers@tesera.example", "none"}, {"tessera.hiring@gmail.com", "pass"},
{"meghna@tessera.example", "fail"},
};
static int distance(const char *a, size_t n, const char *b, size_t m) {
int d[64][64];
for (size_t i = 0; i <= n; i++) {
for (size_t j = 0; j <= m; j++) {
d[i][j] = (int)(i + j);
if (i > 0 && j > 0) {
int change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
int shorter = d[i - 1][j] < d[i][j - 1] ? d[i - 1][j] : d[i][j - 1];
d[i][j] = shorter + 1 < change ? shorter + 1 : change;
}
}
}
return d[n][m];
}
static void assess(const char *domain, const char *result) {
char brand[64];
snprintf(brand, sizeof brand, "%.*s", (int)strcspn(OFFICIAL, "."), OFFICIAL);
size_t label = strcspn(domain, "."), length = strlen(domain);
int edits = distance(domain, label < 63 ? label : 63, brand, strlen(brand));
size_t tail = strlen(OFFICIAL);
bool subdomain = length > tail && domain[length - tail - 1] == '.' &&
strcmp(domain + length - tail, OFFICIAL) == 0;
bool webmail = false;
for (size_t i = 0; i < sizeof WEBMAIL / sizeof WEBMAIL[0]; i++)
webmail = webmail || strcmp(domain, WEBMAIL[i]) == 0;
if (strcmp(domain, OFFICIAL) == 0 || subdomain)
printf(" the employer's domain: %s\n",
strcmp(result, "pass") == 0 ? "checks out" : "forged");
else if (webmail)
printf(" public webmail: not the employer\n");
else if (strstr(domain, brand) != nullptr)
printf(" look-alike, contains %s: scam\n", brand);
else if (edits <= 2)
printf(" look-alike, edit distance %d: scam\n", edits);
else
printf(" another domain: ask the employer\n");
}
int main(void) {
for (size_t i = 0; i < sizeof SENDERS / sizeof SENDERS[0]; i++) {
const char *at = strrchr(SENDERS[i][0], '@');
char domain[256];
snprintf(domain, sizeof domain, "%s",
at != nullptr ? at + 1 : SENDERS[i][0]);
for (char *c = domain; *c != '\0'; c++)
*c = (char)tolower((unsigned char)*c);
printf("%s, DMARC %s\n", SENDERS[i][0], SENDERS[i][1]);
assess(domain, SENDERS[i][1]);
}
} In C: C strings cannot grow, so the domain is copied into a 256-byte buffer to be lowercased, and the table is a fixed 64 by 64, because a DNS label has at most 63 characters.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
const OFFICIAL = 'tessera.example';
const WEBMAIL = ['gmail.com', 'outlook.com'];
const SENDERS = [
['meghna@tessera.example', 'pass'],
['hr@tessera-careers.example', 'pass'],
['offers@tesera.example', 'none'],
['tessera.hiring@gmail.com', 'pass'],
['meghna@tessera.example', 'fail'],
];
function distance(string $a, string $b): int
{
$d = [];
for ($i = 0; $i <= strlen($a); $i++) {
for ($j = 0; $j <= strlen($b); $j++) {
$d[$i][$j] = $i + $j;
if ($i > 0 && $j > 0) {
$change = $d[$i - 1][$j - 1] + ($a[$i - 1] === $b[$j - 1] ? 0 : 1);
$d[$i][$j] = min($d[$i - 1][$j] + 1, $d[$i][$j - 1] + 1, $change);
}
}
}
return $d[strlen($a)][strlen($b)];
}
function assess(string $domain, string $result): string
{
$brand = explode('.', OFFICIAL)[0];
$edits = distance(explode('.', $domain)[0], $brand);
if ($domain === OFFICIAL || str_ends_with($domain, '.' . OFFICIAL)) {
return "the employer's domain: " . ($result === 'pass' ? 'checks out' : 'forged');
}
if (in_array($domain, WEBMAIL, true)) {
return 'public webmail: not the employer';
}
if (str_contains($domain, $brand)) {
return "look-alike, contains {$brand}: scam";
}
if ($edits <= 2) {
return "look-alike, edit distance {$edits}: scam";
}
return 'another domain: ask the employer';
}
foreach (SENDERS as [$sender, $result]) {
$domain = strtolower(substr($sender, strrpos($sender, '@') + 1));
echo "{$sender}, DMARC {$result}\n";
echo ' ' . assess($domain, $result) . "\n";
} In PHP: PHP's built-in levenshtein($a, $b) returns the same distance in one call; the loop is written out so that every language shows the same table.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
const OFFICIAL: &str = "tessera.example";
const WEBMAIL: [&str; 2] = ["gmail.com", "outlook.com"];
const SENDERS: [(&str, &str); 5] = [
("meghna@tessera.example", "pass"),
("hr@tessera-careers.example", "pass"),
("offers@tesera.example", "none"),
("tessera.hiring@gmail.com", "pass"),
("meghna@tessera.example", "fail"),
];
fn distance(a: &str, b: &str) -> usize {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut d: Vec<Vec<usize>> = (0..=a.len())
.map(|i| (0..=b.len()).map(|j| i + j).collect())
.collect();
for i in 1..=a.len() {
for j in 1..=b.len() {
let change = d[i - 1][j - 1] + usize::from(a[i - 1] != b[j - 1]);
d[i][j] = (d[i - 1][j] + 1).min(d[i][j - 1] + 1).min(change);
}
}
d[a.len()][b.len()]
}
fn assess(domain: &str, result: &str) -> String {
let brand = OFFICIAL.split('.').next().unwrap_or(OFFICIAL);
let edits = distance(domain.split('.').next().unwrap_or(domain), brand);
if domain == OFFICIAL || domain.ends_with(&format!(".{OFFICIAL}")) {
let verdict = if result == "pass" {
"checks out"
} else {
"forged"
};
format!("the employer's domain: {verdict}")
} else if WEBMAIL.contains(&domain) {
String::from("public webmail: not the employer")
} else if domain.contains(brand) {
format!("look-alike, contains {brand}: scam")
} else if edits <= 2 {
format!("look-alike, edit distance {edits}: scam")
} else {
String::from("another domain: ask the employer")
}
}
fn main() {
for (sender, result) in SENDERS {
let domain = sender.rsplit('@').next().unwrap_or(sender).to_lowercase();
println!("{sender}, DMARC {result}");
println!(" {}", assess(&domain, result));
}
} In Rust: The table compares bytes, which is exact for these ASCII domains. rsplit('@').next() is the text after the last @, or the whole text when there is none; next() returns an Option all the same, and unwrap_or takes its value without a panic.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
const OFFICIAL = "tessera.example";
const WEBMAIL: readonly string[] = ["gmail.com", "outlook.com"];
const SENDERS: [address: string, result: string][] = [
["meghna@tessera.example", "pass"],
["hr@tessera-careers.example", "pass"],
["offers@tesera.example", "none"],
["tessera.hiring@gmail.com", "pass"],
["meghna@tessera.example", "fail"],
];
function distance(a: string, b: string): number {
const d = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => i + j),
);
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const change = d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, change);
}
}
return d[a.length][b.length];
}
function assess(domain: string, result: string): string {
const brand = OFFICIAL.split(".")[0];
const edits = distance(domain.split(".")[0], brand);
if (domain === OFFICIAL || domain.endsWith("." + OFFICIAL)) {
return `the employer's domain: ${result === "pass" ? "checks out" : "forged"}`;
}
if (WEBMAIL.includes(domain)) return "public webmail: not the employer";
if (domain.includes(brand)) return `look-alike, contains ${brand}: scam`;
if (edits <= 2) return `look-alike, edit distance ${edits}: scam`;
return "another domain: ask the employer";
}
for (const [sender, result] of SENDERS) {
console.log(`${sender}, DMARC ${result}`);
console.log(` ${assess(sender.slice(sender.lastIndexOf("@") + 1).toLowerCase(), result)}`);
} In TypeScript: The tuple type [address: string, result: string] names the two fields of each sender, so the compiler checks every row of the list.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"slices"
"strings"
)
const official = "tessera.example"
var webmail = []string{"gmail.com", "outlook.com"}
var senders = [][2]string{
{"meghna@tessera.example", "pass"},
{"hr@tessera-careers.example", "pass"},
{"offers@tesera.example", "none"},
{"tessera.hiring@gmail.com", "pass"},
{"meghna@tessera.example", "fail"},
}
func distance(a, b string) int {
d := make([][]int, len(a)+1)
for i := range d {
d[i] = make([]int, len(b)+1)
for j := range d[i] {
d[i][j] = i + j
if i > 0 && j > 0 {
change := d[i-1][j-1]
if a[i-1] != b[j-1] {
change++
}
d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, change)
}
}
}
return d[len(a)][len(b)]
}
func assess(domain, result string) string {
brand, _, _ := strings.Cut(official, ".")
label, _, _ := strings.Cut(domain, ".")
edits := distance(label, brand)
switch {
case domain == official || strings.HasSuffix(domain, "."+official):
if result == "pass" {
return "the employer's domain: checks out"
}
return "the employer's domain: forged"
case slices.Contains(webmail, domain):
return "public webmail: not the employer"
case strings.Contains(domain, brand):
return fmt.Sprintf("look-alike, contains %s: scam", brand)
case edits <= 2:
return fmt.Sprintf("look-alike, edit distance %d: scam", edits)
}
return "another domain: ask the employer"
}
func main() {
for _, s := range senders {
domain := strings.ToLower(s[0][strings.LastIndexByte(s[0], '@')+1:])
fmt.Printf("%s, DMARC %s\n", s[0], s[1])
fmt.Printf(" %s\n", assess(domain, s[1]))
}
} In Go: strings.Cut returns the text before the first dot. Go has no conditional expression, so a mismatch adds one to the cost with an if, and min takes the three costs at once.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
let official = "tessera.example"
let webmail = ["gmail.com", "outlook.com"]
let senders: [(address: String, result: String)] = [
("meghna@tessera.example", "pass"),
("hr@tessera-careers.example", "pass"),
("offers@tesera.example", "none"),
("tessera.hiring@gmail.com", "pass"),
("meghna@tessera.example", "fail"),
]
func distance(_ a: String, _ b: String) -> Int {
let (a, b) = (Array(a), Array(b))
var d = (0...a.count).map { i in (0...b.count).map { j in i + j } }
for i in stride(from: 1, through: a.count, by: 1) {
for j in stride(from: 1, through: b.count, by: 1) {
let change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1)
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
}
}
return d[a.count][b.count]
}
func occurs(_ part: String, in text: String) -> Bool {
let (part, text) = (Array(part), Array(text))
guard part.count <= text.count else { return false }
return (0...(text.count - part.count)).contains { Array(text[$0..<$0 + part.count]) == part }
}
func assess(domain: String, result: String) -> String {
let brand = String(official.split(separator: ".")[0])
let label = String(domain.split(separator: ".", omittingEmptySubsequences: false)[0])
let edits = distance(label, brand)
if domain == official || domain.hasSuffix("." + official) {
return "the employer's domain: " + (result == "pass" ? "checks out" : "forged")
}
if webmail.contains(domain) {
return "public webmail: not the employer"
}
if occurs(brand, in: domain) {
return "look-alike, contains \(brand): scam"
}
if edits <= 2 {
return "look-alike, edit distance \(edits): scam"
}
return "another domain: ask the employer"
}
for sender in senders {
let domain = sender.address.split(separator: "@", omittingEmptySubsequences: false).last ?? ""
print("\(sender.address), DMARC \(sender.result)")
print(" \(assess(domain: String(domain).lowercased(), result: sender.result))")
} In Swift: occurs searches for the name itself, because String's contains(_:) for text comes from Swift's regex module, which makes the build for the browser too large. Array(a) gives the table integer indices.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
OFFICIAL = 'tessera.example'
WEBMAIL = %w[gmail.com outlook.com].freeze
SENDERS = [
['meghna@tessera.example', 'pass'],
['hr@tessera-careers.example', 'pass'],
['offers@tesera.example', 'none'],
['tessera.hiring@gmail.com', 'pass'],
['meghna@tessera.example', 'fail']
].freeze
# One row of the table, from the row above it and one letter of the first label.
def next_row(above, letter, second)
second.each_char.with_index.with_object([above.first + 1]) do |(other, j), row|
row << [above[j + 1] + 1, row[j] + 1, above[j] + (letter == other ? 0 : 1)].min
end
end
def distance(first, second)
row = (0..second.length).to_a
first.each_char { |letter| row = next_row(row, letter, second) }
row.last
end
def assess(domain, result)
brand = OFFICIAL.split('.').first
edits = distance(domain.split('.').first, brand)
if domain == OFFICIAL || domain.end_with?(".#{OFFICIAL}")
"the employer's domain: #{result == 'pass' ? 'checks out' : 'forged'}"
elsif WEBMAIL.include?(domain) then 'public webmail: not the employer'
elsif domain.include?(brand) then "look-alike, contains #{brand}: scam"
elsif edits <= 2 then "look-alike, edit distance #{edits}: scam"
else 'another domain: ask the employer'
end
end
SENDERS.each do |sender, result|
puts "#{sender}, DMARC #{result}"
puts " #{assess(sender.rpartition('@').last.downcase, result)}"
end In Ruby: RuboCop limits the size of a method, so the table is built one row at a time; next_row computes each row from the row above it, by the same rule as the full table.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
const val OFFICIAL = "tessera.example"
val webmail = listOf("gmail.com", "outlook.com")
val senders =
listOf(
"meghna@tessera.example" to "pass",
"hr@tessera-careers.example" to "pass",
"offers@tesera.example" to "none",
"tessera.hiring@gmail.com" to "pass",
"meghna@tessera.example" to "fail",
)
fun distance(
a: String,
b: String,
): Int {
val d = Array(a.length + 1) { i -> IntArray(b.length + 1) { j -> i + j } }
for (i in 1..a.length) {
for (j in 1..b.length) {
val change = d[i - 1][j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1
d[i][j] = minOf(d[i - 1][j] + 1, d[i][j - 1] + 1, change)
}
}
return d[a.length][b.length]
}
fun assess(
domain: String,
result: String,
): String {
val brand = OFFICIAL.substringBefore('.')
val edits = distance(domain.substringBefore('.'), brand)
val verdict = if (result == "pass") "checks out" else "forged"
return when {
domain == OFFICIAL || domain.endsWith(".$OFFICIAL") -> "the employer's domain: $verdict"
domain in webmail -> "public webmail: not the employer"
brand in domain -> "look-alike, contains $brand: scam"
edits <= 2 -> "look-alike, edit distance $edits: scam"
else -> "another domain: ask the employer"
}
}
fun main() {
for ((sender, result) in senders) {
println("$sender, DMARC $result")
println(" ${assess(sender.substringAfterLast('@').lowercase(), result)}")
}
} In Kotlin: substringBefore('.') and substringAfterLast('@') return the whole text when the delimiter is missing, so a bare label or an address needs no special case.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
import 'dart:math';
const official = 'tessera.example';
const webmail = ['gmail.com', 'outlook.com'];
const senders = [
('meghna@tessera.example', 'pass'),
('hr@tessera-careers.example', 'pass'),
('offers@tesera.example', 'none'),
('tessera.hiring@gmail.com', 'pass'),
('meghna@tessera.example', 'fail'),
];
int distance(String a, String b) {
final d = List.generate(
a.length + 1,
(i) => List.generate(b.length + 1, (j) => i + j),
);
for (var i = 1; i <= a.length; i++) {
for (var j = 1; j <= b.length; j++) {
final change = d[i - 1][j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
d[i][j] = min(min(d[i - 1][j] + 1, d[i][j - 1] + 1), change);
}
}
return d[a.length][b.length];
}
String assess(String domain, String result) {
final brand = official.split('.').first;
final edits = distance(domain.split('.').first, brand);
if (domain == official || domain.endsWith('.$official')) {
return "the employer's domain: ${result == 'pass' ? 'checks out' : 'forged'}";
}
if (webmail.contains(domain)) return 'public webmail: not the employer';
if (domain.contains(brand)) return 'look-alike, contains $brand: scam';
if (edits <= 2) return 'look-alike, edit distance $edits: scam';
return 'another domain: ask the employer';
}
void main() {
for (final (sender, result) in senders) {
final domain = sender.substring(sender.lastIndexOf('@') + 1).toLowerCase();
print('$sender, DMARC $result');
print(' ${assess(domain, result)}');
}
} In Dart: The loop takes each record apart with final (sender, result), and min from dart:math takes two costs at a time.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
meghna@tessera.example, DMARC pass the employer's domain: checks out hr@tessera-careers.example, DMARC pass look-alike, contains tessera: scam offers@tesera.example, DMARC none look-alike, edit distance 1: scam tessera.hiring@gmail.com, DMARC pass public webmail: not the employer meghna@tessera.example, DMARC fail the employer's domain: forged
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 50 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 152 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 10 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 31 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 726 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 187 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 15 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
Read messages 2 and 5 together. Message 2 passed DMARC and is still a scam, because its domain only resembles the employer’s. Message 5 uses the employer’s real address and failed DMARC, which is how forged mail looks: nothing shows that the domain’s owner sent it. A sender on another domain, such as a recruiting agency’s own, can be genuine, so the program’s last rule says to ask the employer, through a contact on its careers site, whether the agency works for it. Look at the Reply-To header too: a reply that would go to a webmail address instead of the sender’s domain is one more reason to stop.
Email Header Analyzer Paste the headers of a message you received to read its From, Reply-To and Authentication-Results lines.Key idea
Check the domain before the DMARC result: a pass proves only that a domain sent the message, so a look-alike domain is a scam even when it passes.
Checkpoint
Check: Checking the sender's domain
Verify the role and the person before you share documents
Before you reply with anything personal, confirm that the job exists and that the person works for the employer. Find the role on the employer’s own careers site, which Wipro names as the place to verify an offer, and contact the employer through an address or a phone number taken from that site, never from the message. A genuine offer comes as documents to review and sign from the employer’s recruiting team; Microsoft describes its own offers that way. What you share depends on how far a verified process has gone:
| They ask for | A genuine employer asks | Until then |
|---|---|---|
| Money of any kind | Never | Refuse |
| An OTP, a password or a card number | Never | Refuse |
| Bank details or tax forms | After a verified written offer, for payroll | Refuse: Wipro’s recruiters never ask for them |
| Copies of your ID and certificates | For a background check, after a verified offer | Share them only through the employer’s own channel |
Offers of jobs abroad add checks of their own, because the travel and the visa are part of the offer.
Jobs abroad recruited from India: the agent, the contract and the visa
The Ministry of External Affairs asks job seekers to use only a registered recruiting agent: In India, an agency registered with the Ministry of External Affairs to recruit workers for jobs abroad. The list of active agents is published on the eMigrate portal. More in “Career switches, gaps, layoffs and roles abroad”, checked on the list of active agents on the eMigrate portal, whose licence number appears in its offices and adverts. Under the Emigration Act, 1983, such an agent may charge at most ₹30,000 plus 18 % GST, with a receipt. A genuine offer comes with an employment contract that the foreign employer, the agent and you all sign, and you travel on an employment or work visa; a tourist visa is never right for a job. The ministry warns that unregistered agents cheat job seekers with fake offers, overcharge them by ₹2–5 lakh and have led workers into life-threatening conditions, with cases reported in places such as Myanmar and Laos.
The special situations lesson covers the other checks of a role abroad, from visa sponsorship to who your employer would be.
Checkpoint
Check: Verifying the role before you share documents
Report it at once, and faster if you paid
Once a message looks like a scam, the order of what you do decides how much you can save. Step through it, with the branch for when you paid nothing:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Stop, and keep the evidence. Stop replying and do not pay again, whatever is promised. Keep the messages, the numbers, the payment receipts and the transaction IDs; every report below asks for them.
- Did anything leave your hands?. The next steps depend on whether you paid, shared a one-time password, or gave card or bank details. Anything you gave away is the first thing to protect.
- Nothing paid. If you paid nothing and shared no codes, go straight to reporting the message, so that the next person who receives it is warned sooner.
- Your bank first. If you paid, call your bank, card issuer or payment app at once and ask them to stop or reverse the payment, as the FTC advises for every way of paying.
- Report the fraud. In India, call 1930 or file at cybercrime.gov.in, the Home Ministry's portal, which was set up so that frauds are reported at once. In the US, report at ReportFraud.ftc.gov.
- Report the message. Report the number or the account to the app that carried it. In India, Chakshu on the Sanchar Saathi portal takes reports of fraud calls, texts and WhatsApp messages.
- Tell the employer. Tell the employer whose name was used, through the contact on its own careers site. Wipro, for example, asks for such messages at its recruitment helpdesk.
If you paid, call your bank or payment app first: the FTC tells anyone who paid a scammer to contact the company they used to send the money and ask for the payment to be reversed, whatever the method. Then report the fraud, in the US at ReportFraud.ftc.gov, and elsewhere to your country’s police cybercrime or fraud service. Report the number or the account to the app that carried the message, and tell the employer whose name was used, through the contact on its own careers site; Wipro asks for such messages at its recruitment helpdesk and, if you have paid, for a complaint at the nearest police station.
Reporting in India
Call 1930 or file a complaint at cybercrime.gov.in, the Ministry of Home Affairs’ National Cyber Crime Reporting Portal. Its system for financial fraud is built for reports made at once, so that the money can be stopped before it is moved on, and the ministry told Parliament that more than ₹8,690 crore had been saved in more than 24.65 lakh complaints. If you lost nothing, report the call, text or WhatsApp message on Chakshu, on the Department of Telecommunications’ Sanchar Saathi portal, within 30 days of receiving it.
Checkpoint
Check: Reporting a scam and limiting the loss
Practise: sort the messages
Write this lesson’s triage rules as a function, then run the sample tests. They include a subdomain of the employer, a domain that only ends like the employer’s, and an offer from webmail without an interview:
Exercise · Easy · Python
Sort job messages into scam, verify first or likely genuine
A message about a job can be sorted from a few facts about it. Write triage(message), which returns a pair (verdict, flags): the verdict is "scam", "verify first" or "likely genuine", and flags is the list of the message's red flags, in the order below.
The message is a dictionary with these keys:
sender: the domain of the sender's address, such as"careers.tessera.example"or"gmail.com";employer: the employer's own domain, taken from its careers site, such as"tessera.example";asks_money:Truewhen the message asks you to pay anything: a fee, a deposit or a purchase;asks_secret:Truewhen it asks for a one-time password, a password or a card number;interview:"video","in person","chat"or"none";on_careers_site:Truewhen the role is listed on the employer's careers site.
The flags, in this order:
"asks for money"whenasks_moneyis true;"asks for a code or password"whenasks_secretis true;"interview only by chat"wheninterviewis"chat";"no interview"wheninterviewis"none";"sender not on the employer's domain"unless the sender's domain is the employer's domain or a subdomain of it, compared in lower case (careers.tessera.exampleis ontessera.example;nottessera.exampleis not);"role not on the careers site"whenon_careers_siteis false.
The verdict is "scam" when the message asks for money or for a code or password, "verify first" when it has any other flag, and "likely genuine" when it has none.
Starter code · triage.py
"""Sort a message about a job into "scam", "verify first" or "likely genuine", with its red flags."""
def triage(message):
"""The verdict and the red flags of a message.
>>> triage({"sender": "tessera.example", "employer": "tessera.example", "asks_money": False,
... "asks_secret": False, "interview": "video", "on_careers_site": True})
('likely genuine', [])
"""
# Write your solution here.
return "likely genuine", [] The sample tests · test_triage.py
from triage import triage
def message(**changes):
"""A message from the employer's own domain, with an interview and the role listed, changed as asked."""
base = {
"sender": "tessera.example",
"employer": "tessera.example",
"asks_money": False,
"asks_secret": False,
"interview": "video",
"on_careers_site": True,
}
base.update(changes)
return base
def test_a_genuine_message():
"""the employer's domain, an interview and a listed role: no flags"""
assert triage(message()) == ("likely genuine", [])
def test_a_subdomain_and_an_interview_in_person():
"""a subdomain of the employer's domain is the employer's"""
assert triage(message(sender="careers.tessera.example", interview="in person")) == ("likely genuine", [])
def test_domains_ignore_case():
"""domain names are compared in lower case"""
assert triage(message(sender="Careers.Tessera.EXAMPLE", employer="tessera.example")) == ("likely genuine", [])
def test_a_fee_makes_it_a_scam():
"""any request for money is a scam, even from the employer's domain"""
assert triage(message(asks_money=True)) == ("scam", ["asks for money"])
def test_a_one_time_password_makes_it_a_scam():
"""asking for a code or a password is a scam"""
assert triage(message(asks_secret=True, interview="chat")) == (
"scam",
["asks for a code or password", "interview only by chat"],
)
def test_a_name_that_only_ends_like_the_employer():
"""nottessera.example is not on tessera.example"""
assert triage(message(sender="nottessera.example")) == ("verify first", ["sender not on the employer's domain"])
def test_public_webmail_and_no_interview():
"""an offer without an interview, from webmail, is checked first"""
assert triage(message(sender="gmail.com", interview="none")) == (
"verify first",
["no interview", "sender not on the employer's domain"],
)
def test_a_role_that_is_not_listed():
"""a role missing from the careers site is checked first"""
assert triage(message(on_careers_site=False)) == ("verify first", ["role not on the careers site"])
def test_every_flag_in_order():
"""the flags come in the order of the prompt"""
assert triage(
message(
sender="tessera-careers.example",
asks_money=True,
asks_secret=True,
interview="none",
on_careers_site=False,
)
) == (
"scam",
[
"asks for money",
"asks for a code or password",
"no interview",
"sender not on the employer's domain",
"role not on the careers site",
],
) A hint
Build the list of flags first, one if per flag in the order of the prompt, then decide the verdict from it. For the domain, lower-case both names and accept the sender when it equals the employer's domain or ends with a dot followed by it: "." + employer. Without the dot, nottessera.example would pass.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself, weakest idea first:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A recruiter screen, an HR round or a security interview may ask these:
-
Fresher to mid · Warm-up
You receive an offer by email for a role you never interviewed for. What do you check before you reply?
An offer without an interview is already a warning sign, so I check three things before I reply. Money first: if it asks me to pay anything, a fee, a deposit or a purchase, it is a scam and I stop. Then the sender: I compare the domain after the @ with the one on the employer's careers site, accepting only that domain or a subdomain of it, and I treat public webmail and look-alike domains as scams even when DMARC passed. Then the role: I look for it on the careers site and contact the employer through details from that site, never from the message. Until all three check out I share no documents, and I never share a one-time password, a password or a card number. If it is a scam, I report it and tell the employer whose name was used.
-
Fresher to senior · Warm-up
An email that claims to come from an employer passes SPF, DKIM and DMARC. Does that prove the employer sent it?
No. DMARC checks that the domain in the From address authorised the message, so a pass proves that this domain sent it, not that the domain belongs to the employer. A scammer who registers a look-alike such as tessera-careers.example can set up SPF, DKIM and DMARC for it and pass all three; the DMARC standard itself says it does not deal with visually similar domain names. So I first compare the domain with the one on the employer's careers site, exactly or as a subdomain, and only then read the result. I read the Authentication-Results line that my own mail service added, because a sender can write a forged one into the message. A fail on the employer's real domain is the opposite case: the address was forged.
Key takeaways
- Any request for money to get or keep a job marks a scam, whether it is called a fee, a deposit or a top-up; honest employers never charge candidates.
- Contact out of the blue, a chat-only interview, an offer without an interview, pay for easy tasks, a cheque to pass on and pressure to decide at once are the other signs, and a message that shows any of them is verified first.
- Check the sender’s domain against the one on the employer’s careers site, exactly or as a subdomain; a DMARC pass proves only that a domain sent the message, so a look-alike that passes is still a scam.
- Verify the role on the careers site and contact the employer through its own details; never share codes, passwords or card numbers, and share documents only after a verified written offer.
- If you paid, call your bank or payment app at once and report the fraud, at 1930 or cybercrime.gov.in in India and ReportFraud.ftc.gov in the US; then report the message and tell the employer whose name was used.
Terms in this lesson
- DMARC
- Domain-based Message Authentication, Reporting and Conformance: an email standard by which a receiving server checks that the domain in a message's From address authorised it. A pass says nothing about who owns that domain.
- edit distance
- The fewest single-letter changes, insertions or deletions that turn one word into another; tesera is one edit away from tessera.
- fake-cheque scam
- A scam in which a new employer or buyer sends you a cheque and asks you to send part of the money on; the cheque bounces later, and you may owe your bank the full amount.
- look-alike domain
- A domain name made to resemble an employer's own, with a word added or a letter changed, such as tessera-careers.example or tesera.example for tessera.example.
- task scam
- A job scam that pays small sums for simple online tasks, such as liking videos or rating products, then asks for deposits before you may go on or withdraw the balance shown, which is never paid out.
References
- Job scams (US Federal Trade Commission)
- Paying to get paid: gamified job scams drive record losses (US Federal Trade Commission)
- How to avoid a scam (US Federal Trade Commission)
- Report fraud to the FTC (US Federal Trade Commission)
- Fraud awareness: recruitment fraud (Wipro Careers)
- National Career Service: fees and misleading communication (National Career Service, Ministry of Labour and Employment, Government of India)
- Advisory on overcharging by agents for overseas recruitment, fake overseas jobs and illegal recruitment (Ministry of External Affairs, Government of India (Embassy of India, Algiers))
- Indian Cyber Crime Coordination Centre: the 1930 helpline and fraud reporting (Press Information Bureau, Ministry of Home Affairs, Government of India)
- National Cyber Crime Reporting Portal (Ministry of Home Affairs, Government of India)
- Chakshu: report suspected fraud communication (Department of Telecommunications, Government of India)
- Hiring tips: how offers are sent (Microsoft Careers)
- RFC 9989: Domain-Based Message Authentication, Reporting, and Conformance (DMARC) (IETF (RFC Editor))
- RFC 8601: Message Header Field for Indicating Message Authentication Status (IETF (RFC Editor))
- RFC 2606: Reserved Top Level DNS Names (IETF (RFC Editor))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress