Adds the mailing list setup.
This commit is contained in:
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createDefaultPreset } from "ts-jest";
|
||||||
|
|
||||||
|
const tsJestTransformCfg = createDefaultPreset().transform;
|
||||||
|
|
||||||
|
/** @type {import("jest").Config} **/
|
||||||
|
export default {
|
||||||
|
testEnvironment: "node",
|
||||||
|
transform: {
|
||||||
|
...tsJestTransformCfg,
|
||||||
|
},
|
||||||
|
};
|
||||||
16
package.json
16
package.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "WebBox",
|
"name": "web_box",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Web Box is a server-side application that exposes mailing list services over a REST API.",
|
"description": "Web Box is a server-side application that exposes mailing list services over a REST API.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -15,28 +15,34 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc"
|
"build": "tsc",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/nodemailer": "^9.0.6",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/escape-html": "^1.0.4",
|
"@types/escape-html": "^1.0.4",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/mime": "^3.0.4",
|
"@types/mime": "^3.0.4",
|
||||||
"@types/multer": "^2.2.0",
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^26.4.0",
|
"@types/node": "^26.4.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"jest": "^30.5.0",
|
||||||
|
"ts-jest": "^29.4.12",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nodemailer": "^9.0.6",
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"imapflow": "^1.7.7",
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"multer": "^2.3.0",
|
"multer": "^2.3.0",
|
||||||
"node-html-parser": "^9.0.2",
|
"node-html-parser": "^9.0.2",
|
||||||
|
"nodemailer": "^9.0.6",
|
||||||
|
"proper-lockfile": "^4.1.2",
|
||||||
"xmlbuilder2": "^4.0.3"
|
"xmlbuilder2": "^4.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {IncomingMessage} from "node:http";
|
|||||||
|
|
||||||
const CONFIG_PATH = "config_path";
|
const CONFIG_PATH = "config_path";
|
||||||
const BASE_URL = "base_url";
|
const BASE_URL = "base_url";
|
||||||
|
const MAIL_LIST_PATH = "mail_list_path";
|
||||||
const UPLOAD_PATH = "upload_path";
|
const UPLOAD_PATH = "upload_path";
|
||||||
const CACHE_PATH = "cache_path";
|
const CACHE_PATH = "cache_path";
|
||||||
const INDEX_PATH = "index_path";
|
const INDEX_PATH = "index_path";
|
||||||
@@ -17,13 +18,14 @@ const AUTH_KEY = "auth_key";
|
|||||||
const AUTH_KEY_HASH = "auth_key_hash";
|
const AUTH_KEY_HASH = "auth_key_hash";
|
||||||
|
|
||||||
let config: {[key: string]: any} = {};
|
let config: {[key: string]: any} = {};
|
||||||
config[CONFIG_PATH] = "/etc/node_mailer/config";
|
config[CONFIG_PATH] = "/etc/web_box/config";
|
||||||
config[BASE_URL] = "";
|
config[BASE_URL] = "";
|
||||||
config[UPLOAD_PATH] = "/var/lib/node_mailer/uploads";
|
config[MAIL_LIST_PATH] = "/var/lib/web_box/mailing_lists.json";
|
||||||
config[CACHE_PATH] = "/var/cache/node_mailer/output";
|
config[UPLOAD_PATH] = "/var/lib/web_box/uploads";
|
||||||
config[INDEX_PATH] = "/var/cache/node_mailer/index.json";
|
config[CACHE_PATH] = "/var/cache/web_box/output";
|
||||||
config[TMP_PATH] = "/var/cache/node_mailer/tmp";
|
config[INDEX_PATH] = "/var/cache/web_box/index.json";
|
||||||
config[TMP_UPLOAD_PATH] = "/var/cache/node_mailer/upload";
|
config[TMP_PATH] = "/var/cache/web_box/tmp";
|
||||||
|
config[TMP_UPLOAD_PATH] = "/var/cache/web_box/upload";
|
||||||
config[MAX_UPLOAD_SIZE] = 10485760;
|
config[MAX_UPLOAD_SIZE] = 10485760;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +135,7 @@ async function readConfiguration():Promise<{[key: string]: any}>
|
|||||||
export {
|
export {
|
||||||
CONFIG_PATH,
|
CONFIG_PATH,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
|
MAIL_LIST_PATH,
|
||||||
UPLOAD_PATH,
|
UPLOAD_PATH,
|
||||||
CACHE_PATH,
|
CACHE_PATH,
|
||||||
INDEX_PATH,
|
INDEX_PATH,
|
||||||
|
|||||||
@@ -2,7 +2,22 @@
|
|||||||
* Copyright (c) 2026 Markil 3. All rights reserved.
|
* Copyright (c) 2026 Markil 3. All rights reserved.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import nodemailer from 'nodemailer';
|
import path from "node:path";
|
||||||
|
import nodemailer, {Transporter, TransportOptions} from 'nodemailer';
|
||||||
|
import {ExpungeEvent, FetchMessageObject, IdInfoObject, ImapFlow, ImapFlowOptions, MailboxLockObject} from "imapflow";
|
||||||
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
import {ConnectionOptions} from "node:tls";
|
||||||
|
import SMTPPool from "nodemailer/lib/smtp-pool";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import {PathLike, Stats} from "node:fs";
|
||||||
|
import {config, MAIL_LIST_PATH} from "./config";
|
||||||
|
import SendmailTransport from "nodemailer/lib/sendmail-transport";
|
||||||
|
import StreamTransport from "nodemailer/lib/stream-transport";
|
||||||
|
import JSONTransport from "nodemailer/lib/json-transport";
|
||||||
|
import SESTransport from "nodemailer/lib/ses-transport";
|
||||||
|
import SMTPConnection from "nodemailer/lib/smtp-connection";
|
||||||
|
|
||||||
|
const INBOX_FOLDER = "INBOX";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Mail object represents a single email message.
|
* A Mail object represents a single email message.
|
||||||
@@ -11,18 +26,324 @@ class Mail
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
class Mailbox
|
/**
|
||||||
|
* Contains a list of emails that are subscribed to a certain mailing list.
|
||||||
|
*/
|
||||||
|
export class MailingList
|
||||||
{
|
{
|
||||||
constructor()
|
/**
|
||||||
{
|
* The name of the mailing list.
|
||||||
}
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
/**
|
||||||
|
* A list of emails that are subscribed to the list.
|
||||||
|
*/
|
||||||
|
emails: string[];
|
||||||
|
|
||||||
async verify_mail_connection(): boolean
|
public constructor(name: string)
|
||||||
{
|
{
|
||||||
return true;
|
this.name = name;
|
||||||
|
this.emails = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
/**
|
||||||
|
* A unified interface for imap/smtp handling.
|
||||||
|
*/
|
||||||
|
export class Mailbox
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The IMAP service used to collect mail.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly imap: ImapFlow;
|
||||||
|
/**
|
||||||
|
* The SMTP service used to send out mail.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly smtp: Transporter;
|
||||||
|
/**
|
||||||
|
* The directory all mail is stored in.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly mailPath: PathLike;
|
||||||
|
|
||||||
}
|
/**
|
||||||
|
* Contains the mailing lists.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private mailingLists: Map<string, MailingList> = new Map();
|
||||||
|
|
||||||
|
private mailFileMap: Map<string, PathLike> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flag that keeps track of the imap connection status.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private imapConnected: boolean = false;
|
||||||
|
/**
|
||||||
|
* A flag that keeps track of the smtp connection status.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private smtpConnected: boolean = false;
|
||||||
|
/**
|
||||||
|
* A flag that keeps track of the validity of the mail directory.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private directoryValid: boolean = false;
|
||||||
|
|
||||||
|
constructor(imapOptions: ImapFlowOptions, smtpOptions: SMTPConnection.Options, mailPath: PathLike)
|
||||||
|
{
|
||||||
|
this.imap = new ImapFlow(imapOptions);
|
||||||
|
this.imap.on("close", () => {
|
||||||
|
console.log("Dropped IMAP connection.");
|
||||||
|
this.imapConnected = false;
|
||||||
|
});
|
||||||
|
this.smtp = nodemailer.createTransport(smtpOptions);
|
||||||
|
this.mailPath = mailPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes connections to the remote server, resolving when all connections succeed.
|
||||||
|
* @return True when all connections succeed.
|
||||||
|
*/
|
||||||
|
verify_mail_connection(): Promise<boolean>
|
||||||
|
{
|
||||||
|
return Promise.all([
|
||||||
|
this.smtp.verify().then(
|
||||||
|
() => this.smtpConnected = true
|
||||||
|
).catch((reason) => {
|
||||||
|
this.smtpConnected = false;
|
||||||
|
throw reason;
|
||||||
|
}),
|
||||||
|
this.imap.connect().then(
|
||||||
|
() => this.imapConnected = true
|
||||||
|
).catch((reason) => {
|
||||||
|
this.imapConnected = false;
|
||||||
|
throw reason;
|
||||||
|
})
|
||||||
|
]).then(() => true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks to see if the provided mail directory is valid.
|
||||||
|
* @return True if the directory may be used to store mail.
|
||||||
|
*/
|
||||||
|
async verify_mail_directory(): Promise<boolean>
|
||||||
|
{
|
||||||
|
let directoryStats: Stats | undefined;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
directoryStats = await fs.stat(this.mailPath);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
directoryStats = undefined;
|
||||||
|
}
|
||||||
|
if (!directoryStats)
|
||||||
|
{
|
||||||
|
await fs.mkdir(this.mailPath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
directoryStats = await fs.stat(this.mailPath);
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
directoryStats = undefined;
|
||||||
|
}
|
||||||
|
if (!directoryStats)
|
||||||
|
{
|
||||||
|
this.directoryValid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!directoryStats.isDirectory())
|
||||||
|
{
|
||||||
|
this.directoryValid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await fs.access(this.mailPath, fs.constants.O_DIRECTORY | fs.constants.O_RDWR);
|
||||||
|
this.directoryValid = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads saved data from the disk (mailing lists, etc.)
|
||||||
|
*/
|
||||||
|
async load_data(): Promise<void>
|
||||||
|
{
|
||||||
|
let mailingLists = fs.readFile(config[MAIL_LIST_PATH], {encoding: "utf8"}).then((mailingListJson: string) => {
|
||||||
|
let mailingListsRaw: MailingList[] = JSON.parse(mailingListJson);
|
||||||
|
this.mailingLists.clear();
|
||||||
|
for (let listing of mailingListsRaw)
|
||||||
|
{
|
||||||
|
this.mailingLists.set(listing.name, listing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mailingLists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves application data to the disk (mailing lists, etc.)
|
||||||
|
*/
|
||||||
|
async save_data(): Promise<void>
|
||||||
|
{
|
||||||
|
let mailingListRaw = [];
|
||||||
|
for (let listing of this.mailingLists.values())
|
||||||
|
{
|
||||||
|
mailingListRaw.push({
|
||||||
|
"name": listing.name,
|
||||||
|
"emails": listing.emails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mailingLists = fs.writeFile(config[MAIL_LIST_PATH], JSON.stringify(mailingListRaw), {encoding: "utf8"});
|
||||||
|
await mailingLists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new mailing list.
|
||||||
|
* @param listing - The name of the mailing list.
|
||||||
|
* @param emails - A list of email addresses for the mailing list.
|
||||||
|
* @param listing - The mailing list to add.
|
||||||
|
*/
|
||||||
|
addMailingList(listing: string, emails: string[]): void;
|
||||||
|
addMailingList(listing: MailingList): void;
|
||||||
|
addMailingList(listing: MailingList | string, emails?: string[]): void
|
||||||
|
{
|
||||||
|
if (typeof listing == "string")
|
||||||
|
{
|
||||||
|
listing = new MailingList(listing);
|
||||||
|
if (emails instanceof Array)
|
||||||
|
{
|
||||||
|
listing.emails = emails;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.mailingLists.has(listing.name))
|
||||||
|
{
|
||||||
|
throw new Error("Mail list is already defined");
|
||||||
|
}
|
||||||
|
this.mailingLists.set(listing.name, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new email to a mailing list.
|
||||||
|
* @param email - The email to register.
|
||||||
|
* @param listing - The email list to add the email to.
|
||||||
|
*/
|
||||||
|
registerEmail(email: string, listing: string | MailingList)
|
||||||
|
{
|
||||||
|
if (typeof listing == "string")
|
||||||
|
{
|
||||||
|
listing = (this.mailingLists.get(listing) as MailingList);
|
||||||
|
}
|
||||||
|
if (listing.emails.includes(email))
|
||||||
|
{
|
||||||
|
throw new Error("Email \"" + email + "\" is already registered to \"" + listing.name + "\"");
|
||||||
|
}
|
||||||
|
listing.emails.push(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an email from a mailing list.
|
||||||
|
* @param email - The email to unregister.
|
||||||
|
* @param listing - The email list to remove the email from.
|
||||||
|
*/
|
||||||
|
unregisterEmail(email: string, listing: string | MailingList)
|
||||||
|
{
|
||||||
|
if (typeof listing == "string")
|
||||||
|
{
|
||||||
|
listing = (this.mailingLists.get(listing) as MailingList);
|
||||||
|
}
|
||||||
|
let index = listing.emails.indexOf(email);
|
||||||
|
if (index == -1)
|
||||||
|
{
|
||||||
|
throw new Error("Email \"" + email + "\" is not registered to \"" + listing.name + "\"");
|
||||||
|
}
|
||||||
|
listing.emails.splice(index,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an email from all mailing lists
|
||||||
|
* @param email - The email to un register.
|
||||||
|
*/
|
||||||
|
unregisterEmailFromAll(email: string)
|
||||||
|
{
|
||||||
|
for (let listing of this.mailingLists.values())
|
||||||
|
{
|
||||||
|
this.unregisterEmail(email, listing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains all emails for an email list.
|
||||||
|
* @param listing - The mailing list to search.
|
||||||
|
*/
|
||||||
|
getEmails(listing: string): string[] | undefined
|
||||||
|
{
|
||||||
|
return this.mailingLists.get(listing)?.emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(): Promise<boolean>
|
||||||
|
{
|
||||||
|
if (!this.imapConnected || !this.smtpConnected)
|
||||||
|
{
|
||||||
|
await this.verify_mail_connection();
|
||||||
|
}
|
||||||
|
if (!this.directoryValid)
|
||||||
|
{
|
||||||
|
await this.verify_mail_directory();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads new mail from the IMAP inbox and sorts it.
|
||||||
|
*/
|
||||||
|
async readMail(): Promise<void>
|
||||||
|
{
|
||||||
|
let lock: MailboxLockObject | undefined;
|
||||||
|
let messages: FetchMessageObject[];
|
||||||
|
let messageUids: false | number[];
|
||||||
|
|
||||||
|
await this.verify();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock = await this.imap.getMailboxLock(INBOX_FOLDER);
|
||||||
|
messageUids = await this.imap.search({
|
||||||
|
seen: false
|
||||||
|
}, {
|
||||||
|
uid: true
|
||||||
|
});
|
||||||
|
if (messageUids && messageUids.length > 0)
|
||||||
|
{
|
||||||
|
messages = await this.imap.fetchAll(messageUids, {
|
||||||
|
envelope: true,
|
||||||
|
bodyStructure: true
|
||||||
|
}, { uid: true });
|
||||||
|
for (let message of messages)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (lock)
|
||||||
|
{
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuts down all resources.
|
||||||
|
*/
|
||||||
|
async close(): Promise<void>
|
||||||
|
{
|
||||||
|
this.smtp.close();
|
||||||
|
this.smtpConnected = false;
|
||||||
|
await this.imap.logout().finally(() => this.imapConnected = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
32
tests/mailer_framework.test.ts
Normal file
32
tests/mailer_framework.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "@jest/globals";
|
||||||
|
import { Mailbox } from '../src/mailer_framework';
|
||||||
|
|
||||||
|
describe('Mailer Framework Test', () =>
|
||||||
|
{
|
||||||
|
let mailbox: Mailbox = new Mailbox({host: "", port: 0}, {host: "", port: 0}, "");
|
||||||
|
test('Should only have one mailing list', () => {
|
||||||
|
mailbox.addMailingList("test1", ["sample@gmail.com"]);
|
||||||
|
expect(mailbox.getEmails("test1")).toMatchObject(["sample@gmail.com"]);
|
||||||
|
});
|
||||||
|
test("Mailing list corruption", () =>
|
||||||
|
{
|
||||||
|
// Make sure the first one does not change.
|
||||||
|
mailbox.addMailingList("test2", ["sample@gmail.com", "sample2@gmail.com"]);
|
||||||
|
expect(mailbox.getEmails("test1")).toMatchObject(["sample@gmail.com"]);
|
||||||
|
expect(mailbox.getEmails("test2")).toMatchObject(["sample@gmail.com", "sample2@gmail.com"]);
|
||||||
|
});
|
||||||
|
test("Deregistration test", () =>
|
||||||
|
{
|
||||||
|
// Make sure the first one does not change.
|
||||||
|
mailbox.unregisterEmailFromAll("sample@gmail.com");
|
||||||
|
expect(mailbox.getEmails("test1")).toMatchObject([]);
|
||||||
|
expect(mailbox.getEmails("test2")).toMatchObject(["sample2@gmail.com"]);
|
||||||
|
});
|
||||||
|
test("Existing registration test", () =>
|
||||||
|
{
|
||||||
|
// Make sure the first one does not change.
|
||||||
|
mailbox.registerEmail("sample3@gmail.com", "test1");
|
||||||
|
expect(mailbox.getEmails("test1")).toMatchObject(["sample3@gmail.com"]);
|
||||||
|
expect(mailbox.getEmails("test2")).toMatchObject(["sample2@gmail.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2016",
|
"target": "es2016",
|
||||||
"module": "commonjs",
|
"module": "ES2020",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node", "express", "cors", "multer", "nodemailer", "bcrypt", "escape-html", "mime"]
|
"types": ["node", "express", "cors", "multer", "nodemailer", "bcrypt", "escape-html", "mime"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user