How to set up SMTP for account recovery in MU Online
Set up SMTP email delivery on your MU Online server website and implement a secure token-based password recovery flow, with PHPMailer, expiration and abuse protection.
Nothing frustrates a player more than losing access to their account with no way to recover it. On MU Online servers, email password recovery is the standard solution — but it only works if the site can send emails reliably, and that depends on a well-configured SMTP. This tutorial covers both halve
Nothing frustrates a player more than losing access to their account with no way to recover it. On MU Online servers, email password recovery is the standard solution — but it only works if the site can send emails reliably, and that depends on a well-configured SMTP. This tutorial covers both halves of the problem: first getting the server to send emails that actually reach the inbox (not the spam folder), and then building a secure password recovery flow, with a single-use token, expiration and abuse protection. The table names, columns and the password hash format appear as examples and vary by version of your MuServer, so confirm each one against your database. If you are still building the foundation, see the how to create a MU Online server guide.
Prerequisites
Sending email brings together three things that must be aligned: the site, an SMTP provider and your domain's DNS.
- A working PHP server website with access to the database where the accounts live (
MEMB_INFO, for example). - PHP 7.4 or higher with
opensslenabled (required for TLS over SMTP). - Composer installed, to pull in the PHPMailer library.
- An account with an SMTP provider — this can be Gmail (testing), or a transactional service such as Brevo, Mailgun or Amazon SES (production).
- Access to your domain's DNS panel, to create the SPF, DKIM and DMARC records.
- A dedicated sender address, such as
[email protected].
| SMTP provider | Best for | Typical limit (varies) |
|---|---|---|
| Gmail (app password) | Testing, small server | ~500 emails/day |
| Brevo / Sendinblue | Small/medium production | Free plan ~300/day |
| Mailgun | Production with volume | Paid per thousand |
| Amazon SES | High volume, low cost | Scales on demand |
Why not use the native mail() function
The first instinct is to use PHP's mail() function, but it is the number-one cause of emails landing in the spam folder on private servers. It sends directly through the local server, almost always without SMTP authentication, without aligned SPF and from an IP with a bad reputation. The result is predictable: Gmail and Outlook reject it or mark it as junk. The solution is to send through an authenticated SMTP server using a dedicated library — PHPMailer is the most established one. It handles the TLS negotiation, the authentication and the correct header formatting.
Install it via Composer:
composer require phpmailer/phpmailer
Configuring the SMTP credentials
Keep the credentials out of versioned code, in a separate configuration file with restricted permissions. Never commit SMTP passwords to a repository.
<?php
// config/smtp.php (outside the public root if possible)
return [
'host' => 'smtp-relay.brevo.com', // varies by provider
'porta' => 587, // 587 STARTTLS or 465 SSL
'seguranca' => 'tls', // 'tls' or 'ssl'
'usuario' => 'your-smtp-username',
'senha' => 'YOUR_PASSWORD_OR_API_KEY',
'remetente' => '[email protected]',
'nome_de' => 'ViciadosMU Server',
];
Sending function with PHPMailer
Centralize sending in a single reusable function. That way, both password recovery and any other email (welcome, VIP notice) use the same tested path.
<?php
// src/Mailer.php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/../vendor/autoload.php';
function enviarEmail(string $para, string $assunto, string $corpoHtml): bool
{
$cfg = require __DIR__ . '/../config/smtp.php';
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = $cfg['host'];
$mail->SMTPAuth = true;
$mail->Username = $cfg['usuario'];
$mail->Password = $cfg['senha'];
$mail->SMTPSecure = $cfg['seguranca']; // tls | ssl
$mail->Port = $cfg['porta'];
$mail->CharSet = 'UTF-8';
$mail->setFrom($cfg['remetente'], $cfg['nome_de']);
$mail->addAddress($para);
$mail->isHTML(true);
$mail->Subject = $assunto;
$mail->Body = $corpoHtml;
// Text version for clients without HTML
$mail->AltBody = strip_tags($corpoHtml);
$mail->send();
return true;
} catch (Exception $e) {
error_log('[SMTP] Send failed: ' . $mail->ErrorInfo);
return false;
}
}
Test it in isolation before integrating anything. Create a quick script that calls enviarEmail('[email protected]', 'SMTP Test', '<b>It worked!</b>') and confirm you receive it. If it lands in spam already at this test, the problem is in the DNS, not in the code.
Configuring SPF, DKIM and DMARC
This is the step that most impacts deliverability and the one most often ignored. These three DNS records tell email servers that your domain authorizes that SMTP to send on its behalf.
- SPF — a
TXTrecord that lists who can send through your domain. The SMTP provider supplies the exact value; something likev=spf1 include:spf.brevo.com ~all. - DKIM — a cryptographic signature. The provider generates a key pair and you publish the public key in a
TXTrecord (orCNAME) on the indicated subdomain. - DMARC — a policy that says what to do with emails that fail SPF/DKIM. Start lenient:
v=DMARC1; p=none; rua=mailto:[email protected], and later tighten it top=quarantine.
After publishing, wait for propagation (it can take hours) and validate with SPF/DKIM checking tools. Only move to production once all three pass.
Preparing the database for the recovery token
The recovery flow needs a place to store the temporary tokens. Create a dedicated table — do not mix it with the game's accounts table.
-- Example (varies by version): recovery tokens table
CREATE TABLE WEB_PASSWORD_RESET (
id INT IDENTITY(1,1) PRIMARY KEY,
conta VARCHAR(10) NOT NULL,
token_hash CHAR(64) NOT NULL, -- sha256 of the token
expira_em DATETIME NOT NULL,
usado BIT NOT NULL DEFAULT 0,
ip_origem VARCHAR(45) NULL,
criado_em DATETIME NOT NULL DEFAULT GETDATE()
);
Storing only the token_hash (and not the raw token) is a deliberate security decision: even with access to the database, no one can assemble the recovery link.
Stage 1: request recovery
The player provides their email. The system looks up the associated account, generates a strong random token, stores the hash and sends the link by email. One important detail: the response to the user must be the same whether or not the account exists, so as not to reveal which emails are registered.
<?php
// solicitar-recuperacao.php
require 'src/Mailer.php';
require 'src/db.php';
function solicitarRecuperacao(string $email): void
{
$pdo = getPDO();
// Look up the account by email (column varies by version)
$stmt = $pdo->prepare("SELECT memb_id FROM MEMB_INFO WHERE mail_addr = ?");
$stmt->execute([$email]);
$conta = $stmt->fetchColumn();
// Always respond the same way, whether or not the account exists
if ($conta) {
$token = bin2hex(random_bytes(32)); // raw token (only in the email)
$tokenHash = hash('sha256', $token);
$expira = date('Y-m-d H:i:s', time() + 1800); // 30 min
$ins = $pdo->prepare(
"INSERT INTO WEB_PASSWORD_RESET (conta, token_hash, expira_em, ip_origem)
VALUES (?, ?, ?, ?)"
);
$ins->execute([$conta, $tokenHash, $expira, $_SERVER['REMOTE_ADDR'] ?? null]);
$link = "https://yourserver.com/redefinir?conta=" . urlencode($conta)
. "&token=" . $token;
$html = "<p>We received a request to reset your password.</p>
<p><a href=\"$link\">Click here to create a new password</a>.</p>
<p>The link expires in 30 minutes. If this wasn't you, ignore this email.</p>";
enviarEmail($email, 'Password recovery - ViciadosMU', $html);
}
// Neutral message for the user, always
echo 'If this email is registered, you will receive the instructions shortly.';
}
Stage 2: validate the token and reset the password
When the player clicks the link, the system validates the token against the hash, checks expiration and use, and only then allows setting the new password. At the end, the token is marked as used — never reusable again.
<?php
// redefinir.php
require 'src/db.php';
function redefinirSenha(string $conta, string $token, string $novaSenha): array
{
$pdo = getPDO();
if (strlen($novaSenha) < 6) {
return ['ok' => false, 'msg' => 'The password must be at least 6 characters'];
}
$stmt = $pdo->prepare(
"SELECT id, expira_em, usado FROM WEB_PASSWORD_RESET
WHERE conta = ? AND token_hash = ?"
);
$stmt->execute([$conta, hash('sha256', $token)]);
$reg = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$reg) {
return ['ok' => false, 'msg' => 'Invalid link'];
}
if ($reg['usado']) {
return ['ok' => false, 'msg' => 'This link has already been used'];
}
if (strtotime($reg['expira_em']) < time()) {
return ['ok' => false, 'msg' => 'This link has expired. Request a new one.'];
}
// Password format VARIES BY VERSION — check your MuServer's
// Common example: uppercase MD5
$senhaCript = strtoupper(md5($novaSenha));
$pdo->beginTransaction();
$pdo->prepare("UPDATE MEMB_INFO SET memb_pw = ? WHERE memb_id = ?")
->execute([$senhaCript, $conta]);
$pdo->prepare("UPDATE WEB_PASSWORD_RESET SET usado = 1 WHERE id = ?")
->execute([$reg['id']]);
$pdo->commit();
return ['ok' => true, 'msg' => 'Password changed successfully! You can now log in to the game.'];
}
Using a transaction guarantees that the password is only considered changed if the token is also invalidated in the same move — no window for reuse.
Protecting against abuse
An open recovery form is an invitation to abuse: someone can fire off hundreds of emails or try to guess tokens. A few simple defenses handle most of it:
- Rate limit per IP and per account — at most, for example, 3 requests every 15 minutes.
- A long, random token — 32 bytes from
random_bytesare impossible to brute-force. - CAPTCHA on the request form, to hold off bots.
- Short expiration — 15 to 30 minutes is enough for the player to act.
- Invalidation of old tokens — when generating a new one, mark the previous ones for the same account as used.
- Attempt logging, storing IP and timestamp, to investigate suspicious spikes.
Common errors and fixes
| Error | Likely cause | Fix |
|---|---|---|
| Email always lands in spam | Missing SPF/DKIM/DMARC | Publish the three DNS records and validate before production |
SMTP connect() failed | Wrong port/security or firewall | Check port 587/465 and open the outbound rule in the server firewall |
Could not authenticate | Wrong SMTP username or password | Generate an app password (Gmail) or API key (transactional) and retest |
openssl missing | Extension disabled in PHP | Enable extension=openssl in php.ini and restart |
| Recovery link does not work | Comparing raw token with a hash | Apply hash('sha256', $token) in the validation |
| Reusable token | Missing usado = 1 update | Invalidate the token inside the same transaction as the password change |
| Leak of existing accounts | Different messages per email | Always respond with the same neutral message |
Testing the full flow
- Configure SMTP and send an isolated test email — confirm it reaches the inbox, not spam.
- Publish SPF, DKIM and DMARC and re-validate the test.
- Request recovery with a registered email and confirm the link arrives.
- Request with a nonexistent email and verify the displayed message is identical.
- Click the link, set a new password and log in to the game with it.
- Try to use the same link again — it must be refused as "already used".
- Wait for the expiration and test an expired link — it must ask for a new request.
Launch checklist
- PHPMailer installed via Composer and
opensslenabled - SMTP credentials in a file outside version control
- Test email arriving in the inbox (not spam)
- SPF, DKIM and DMARC published and validated
WEB_PASSWORD_RESETtable created- Token stored only as a
sha256hash - Token expiration configured (15-30 min)
- Token invalidated after first use (transaction)
- Neutral message that does not reveal existing accounts
- Rate limiting and CAPTCHA on the request form
- Password format confirmed for your MuServer version
- Full flow tested end to end
Frequently asked questions
Can I use Gmail as the server's SMTP?
You can for testing and small servers, but Gmail imposes daily limits and requires an app password. For production, a transactional provider (Brevo, Mailgun, Amazon SES) delivers better and won't lock your account.
Why do my site's emails land in spam?
Almost always due to missing SPF, DKIM and DMARC on the domain, or sending from an IP with a bad reputation. Setting up these three DNS records is the single step that most improves deliverability.
Does the recovery token need to expire?
Yes. A token with no expiration becomes a permanent key to break into the account. The standard is to expire it in 15 to 60 minutes and invalidate it as soon as it is used once.
Do I need to store the token in plain text in the database?
No, and you shouldn't. Store only the hash of the token. If the database leaks, no one can reconstruct the recovery links from the hashes.
SMTP with SSL or TLS, which should I choose?
Use port 587 with STARTTLS on most modern providers, or port 465 with implicit SSL when the provider requires it. Avoid port 25 without encryption, which is usually blocked and insecure.