Repository: whitef0x0/node-email-verification
Branch: master
Commit: b45be17e6951
Files: 13
Total size: 55.6 KB
Directory structure:
gitextract_zjuzp3q7/
├── .github/
│ └── PULL_REQUEST_TEMPLATE
├── .gitignore
├── .jshintrc
├── .npmignore
├── .travis.yml
├── README.md
├── examples/
│ └── express/
│ ├── app/
│ │ └── userModel.js
│ ├── index.html
│ ├── server.js
│ └── server_promisified.js
├── index.js
├── package.json
└── test/
└── tests.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/PULL_REQUEST_TEMPLATE
================================================
<!-- Fill out this template to explain your pull request. -->
#### What's in this pull request?
# Fixed confirmTempUser() function to copy all the properties
# of the tempUserData variable to userData.
#### Bugs Squashed
Resolves issue #68.
#### Changes proposed
- Used updated version of nodemailer to ^4.0.1.
- Changed TempUser.findOne() query in the confirmTempUser() function in index.js,
- the previous line has been commented out and added another one after variable declaration
- in if(tempUserData) statement.
================================================
FILE: .gitignore
================================================
.idea/
node_modules
*.log
*.sublime-*
TEST.js
tempuser.js
# compiled markdown
README.html
.DS_Store
development.js
================================================
FILE: .jshintrc
================================================
{
"maxerr" : 100, // {int} Maximum error before stopping
// Enforcing
"bitwise" : false, // true: Prohibit bitwise operators (&, |, ^, etc.)
"camelcase" : false, // true: Identifiers must be in camelCase
"curly" : true, // true: Require {} for every new block or scope
"eqeqeq" : true, // true: Require triple equals (===) for comparison
"forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty()
"freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc.
"immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());`
"indent" : 2, // {int} Number of spaces to use for indentation
"latedef" : true, // true: Require variables/functions to be defined before being used
"newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()`
"noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee`
"noempty" : true, // true: Prohibit use of empty blocks
"nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters.
"nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment)
"plusplus" : false, // true: Prohibit use of `++` & `--`
"quotmark" : "single", // Quotation mark consistency:
// false : do nothing (default)
// true : ensure whatever is used is consistent
// "single" : require single quotes
// "double" : require double quotes
"undef" : true, // true: Require all non-global variables to be declared (prevents global leaks)
"unused" : true, // true: Require all defined variables be used
"strict" : false, // true: Requires all functions run in ES5 Strict Mode
"maxparams" : false, // {int} Max number of formal params allowed per function
"maxdepth" : false, // {int} Max depth of nested blocks (within functions)
"maxstatements" : false, // {int} Max number statements per function
"maxcomplexity" : false, // {int} Max cyclomatic complexity per function
"maxlen" : false, // {int} Max number of characters per line
// Relaxing
"asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons)
"boss" : false, // true: Tolerate assignments where comparisons would be expected
"debug" : false, // true: Allow debugger statements e.g. browser breakpoints.
"eqnull" : false, // true: Tolerate use of `== null`
"esversion" : "6",
"moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features)
// (ex: `for each`, multiple try/catch, function expression…)
"evil" : false, // true: Tolerate use of `eval` and `new Function()`
"expr" : false, // true: Tolerate `ExpressionStatement` as Programs
"funcscope" : false, // true: Tolerate defining variables inside control statements
"globalstrict" : false, // true: Allow global "use strict" (also enables 'strict')
"iterator" : false, // true: Tolerate using the `__iterator__` property
"lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block
"laxbreak" : false, // true: Tolerate possibly unsafe line breakings
"laxcomma" : false, // true: Tolerate comma-first style coding
"loopfunc" : false, // true: Tolerate functions being defined in loops
"multistr" : false, // true: Tolerate multi-line strings
"noyield" : false, // true: Tolerate generator functions with no yield statement in them.
"notypeof" : false, // true: Tolerate invalid typeof operator values
"proto" : false, // true: Tolerate using the `__proto__` property
"scripturl" : false, // true: Tolerate script-targeted URLs
"shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;`
"sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation
"supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;`
"validthis" : false, // true: Tolerate using this in a non-constructor function
// Environments
"browser" : true, // Web Browser (window, document, etc)
"browserify" : false, // Browserify (node.js code in the browser)
"couch" : false, // CouchDB
"devel" : true, // Development/debugging (alert, confirm, etc)
"dojo" : false, // Dojo Toolkit
"jasmine" : false, // Jasmine
"jquery" : false, // jQuery
"mocha" : true, // Mocha
"mootools" : false, // MooTools
"node" : true, // Node.js
"nonstandard" : false, // Widely adopted globals (escape, unescape, etc)
"prototypejs" : false, // Prototype and Scriptaculous
"qunit" : false, // QUnit
"rhino" : false, // Rhino
"shelljs" : false, // ShellJS
"worker" : false, // Web Workers
"wsh" : false, // Windows Scripting Host
"yui" : false, // Yahoo User Interface
// Custom Globals
"globals" : {} // additional predefined global variables
}
================================================
FILE: .npmignore
================================================
examples
*.sublime-*
*.log
README.html
test
.github
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- "7"
- "6"
- "6.1"
- "5.11"
services:
- mongodb
================================================
FILE: README.md
================================================
# <img src="https://github.com/whitef0x0/node-email-verification/raw/master/design/logo.png" data-canonical-src="https://github.com/whitef0x0/node-email-verification/raw/master/design/logo.png" width="48"/> Node Email Verification
[](https://travis-ci.org/whitef0x0/node-email-verification)
[](https://gitter.im/whitef0x0/node-email-verification?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[](https://nodei.co/npm/email-verification/)
Verify user signup over email with NodeJS and MongoDB!
The way this works is as follows:
- temporary user is created with a randomly generated URL assigned to it and then saved to a MongoDB collection
- email is sent to the email address the user signed up with
- when the URL is accessed, the user's data is transferred to the real collection
A temporary user document has a TTL of 24 hours by default, but this (as well as many other things) can be configured. See the options section for more details. It is also possible to resend the verification email if needed.
## Installation
via npm:
```
npm install email-verification
```
## Quick Example/Guide
**Before you start, make sure you have a directory structure like so:**
```
app/
-- userModel.js
-- tempUserModel.js
node_modules/
server.js
```
### Step 1: Add your dependencies
All of the code in this section takes place in server.js. Note that `mongoose` has to be passed as an argument when requiring the module:
```javascript
var User = require('./app/userModel'),
mongoose = require('mongoose'),
nev = require('email-verification')(mongoose);
mongoose.connect('mongodb://localhost/YOUR_DB');
```
### Step 2: Configure your settings
Next, make sure to configure the options (see the section below for more extensive detail on this):
```javascript
nev.configure({
verificationURL: 'http://myawesomewebsite.com/email-verification/${URL}',
persistentUserModel: User,
tempUserCollection: 'myawesomewebsite_tempusers',
transportOptions: {
service: 'Gmail',
auth: {
user: 'myawesomeemail@gmail.com',
pass: 'mysupersecretpassword'
}
},
verifyMailOptions: {
from: 'Do Not Reply <myawesomeemail_do_not_reply@gmail.com>',
subject: 'Please confirm account',
html: 'Click the following link to confirm your account:</p><p>${URL}</p>',
text: 'Please confirm your account by clicking the following link: ${URL}'
}
}, function(error, options){
});
```
Note: Any options not included in the object you pass will take on the default value specified in the section below. Calling `configure` multiple times with new options will simply change the previously defined options.
### Step 3: Create a Temporary user Model
To create a temporary user model, you can either generate it using a built-in function, or you can predefine it in a separate file. If you are pre-defining it, it must be IDENTICAL to the user model with an extra field for the URL; the default one is `GENERATED_VERIFYING_URL: String`.
```javascript
// configuration options go here...
// generating the model, pass the User model defined earlier
nev.generateTempUserModel(User);
// using a predefined file
var TempUser = require('./app/tempUserModel');
nev.configure({
tempUserModel: TempUser
}, function(error, options){
});
```
### Step 4: Create a TempUser Model in your Signup Handler
Then, create an instance of the User model, and then pass it as well as a custom callback to `createTempUser`. Inside your `createTempUser` callback, make a call to the `sendVerificationEmail` function.
```javascript
// get the credentials from request parameters or something
var email = "...",
password = "...";
var newUser = User({
email: email,
password: password
});
nev.createTempUser(newUser, function(err, existingPersistentUser, newTempUser) {
// some sort of error
if (err)
// handle error...
// user already exists in persistent collection...
if (existingPersistentUser)
// handle user's existence... violently.
// a new user
if (newTempUser) {
var URL = newTempUser[nev.options.URLFieldName];
nev.sendVerificationEmail(email, URL, function(err, info) {
if (err)
// handle error...
// flash message of success
});
// user already exists in temporary collection...
} else {
// flash message of failure...
}
});
```
### Step 4.5: Hash your users password
Note: An email will be sent to the email address that the user signed up with. If you are interested in hashing the password (which you probably should be), all you need to do is set the option `hashingFunction` to a function that takes the parameters `password, tempUserData, insertTempUser, callback` and returns `insertTempUser(hash, tempUserData, callback)`, e.g.:
```javascript
// sync version of hashing function
var myHasher = function(password, tempUserData, insertTempUser, callback) {
var hash = bcrypt.hashSync(password, bcrypt.genSaltSync(8), null);
return insertTempUser(hash, tempUserData, callback);
};
// async version of hashing function
myHasher = function(password, tempUserData, insertTempUser, callback) {
bcrypt.genSalt(8, function(err, salt) {
bcrypt.hash(password, salt, function(err, hash) {
return insertTempUser(hash, tempUserData, callback);
});
});
};
```
### Step 5: Confirm your user and save your user to persistent storage
To move a user from the temporary storage to 'persistent' storage (e.g. when they actually access the URL we sent them), we call `confirmTempUser`, which takes the URL as well as a callback with two parameters: an error, and the instance of the User model (or `null` if there are any errors, or if the user wasn't found - i.e. their data expired).
If you want to send a confirmation email, note that in your options the `shouldSendConfirmation` default value is true, which means that on calling `confirmTempUser` you will automatically send a confirmation e-mail. Creating a call to `sendConfirmationEmail` will end up sending two confirmation e-mails to the user. In your configurations, you should either have `shouldSendConfirmation` equal true or use `sendConfirmationEmail`.
If `shouldSendConfirmation` is false and you want to send a confirmation email, you need to make a call to the `sendConfirmationEmail` function, inside the `confirmTempUser` callback, which takes two parameters: the user's email and a callback. This callback takes two parameters: an error if any occured, and the information returned by Nodemailer.
```javascript
var url = '...';
nev.confirmTempUser(url, function(err, user) {
if (err)
// handle error...
// user was found!
if (user) {
// optional
nev.sendConfirmationEmail(user['email_field_name'], function(err, info) {
// redirect to their profile...
});
}
// user's data probably expired...
else
// redirect to sign-up
});
```
### Step 5.5: Allow user to resend verification email
If you want the user to be able to request another verification email, simply call `resendVerificationEmail`, which takes the user's email address and a callback with two parameters: an error, and a boolean representing whether or not the user was found.
```javascript
var email = '...';
nev.resendVerificationEmail(email, function(err, userFound) {
if (err)
// handle error...
if (userFound)
// email has been sent
else
// flash message of failure...
});
```
To see a fully functioning example that uses Express as the backend, check out the [**examples section**](https://github.com/SaintDako/node-email-verification/tree/master/examples/express).
**NEV supports Bluebird's PromisifyAll!** Check out the examples section for that too.
## API
* [`configure`](#configure)
* [`generateTempUserModel`](#generateTempUserModel)
* [`createTempUser`](#createTempUser)
* [`sendVerificationEmail`](#sendVerificationEmail)
* [`confirmTempUser`](#confirmTempUser)
* [`sendConfirmationEmail`](#Options)
* [`resendVerificationEmail`](#resendVerificationEmail)
* [`Options`](#Options)
<a name="configure"></a>
### `configure(optionsToConfigure, callback(err, options))`
Changes the default configuration by passing an object of options to configure (`optionsToConfigure`); see the section below for a list of all options. `options` will be the result of the configuration, with the default values specified below if they were not given. If there are no errors, `err` is `null`.
<a name="generateTempUserModel"></a>
### `generateTempUserModel(UserModel, callback(err, tempUserModel))`
Generates a Mongoose Model for the temporary user based off of `UserModel`, the persistent user model. The temporary model is essentially a duplicate of the persistent model except that it has the field `{GENERATED_VERIFYING_URL: String}` for the randomly generated URL by default (the field name can be changed in the options). If the persistent model has the field `createdAt`, then an expiration time (`expires`) is added to it with a default value of 24 hours; otherwise, the field is created as such:
```javascript
{
...
createdAt: {
type: Date,
expires: 86400,
default: Date.now
}
...
}
```
`tempUserModel` is the Mongoose model that is created for the temporary user. If there are no errors, `err` is `null`.
Note that `createdAt` will not be transferred to persistent storage (yet?).
<a name="createTempUser"></a>
### `createTempUser(user, callback(err, newTempUser))`
Attempts to create an instance of a temporary user model based off of an instance of a persistent user, `user`, and add it to the temporary collection. `newTempUser` is the temporary user instance if the user doesn't exist in the temporary collection, or `null` otherwise. If there are no errors, `err` is `null`.
If a temporary user model hasn't yet been defined (generated or otherwise), `err` will NOT be `null`.
<a name="sendVerificationEmail"></a>
### `sendVerificationEmail(email, url, callback(err, info))`
Sends a verification email to to the email provided, with a link to the URL to verify the account. If sending the email succeeds, then `err` will be `null` and `info` will be some value. See [Nodemailer's documentation](https://github.com/andris9/Nodemailer#sending-mail) for information.
<a name="confirmTempUser"></a>
### `confirmTempUser(url, callback(err, newPersistentUser))`
Transfers a temporary user (found by `url`) from the temporary collection to the persistent collection and removes the URL assigned with the user. `newPersistentUser` is the persistent user instance if the user has been successfully transferred (i.e. the user accessed URL before expiration) and `null` otherwise; this can be used for redirection and what not. If there are no errors, `err` is `null`.
<a name="sendConfirmationEmail"></a>
### `sendConfirmationEmail(email, callback(err, info))`
Sends a confirmation email to to the email provided. If sending the email succeeds, then `err` will be `null` and `info` will be some value. See [Nodemailer's documentation](https://github.com/andris9/Nodemailer#sending-mail) for information.
<a name="resendVerificationEmail"></a>
### `resendVerificationEmail(email, callback(err, userFound))`
Resends the verification email to a user, given their email. `userFound` is `true` if the user has been found in the temporary collection (i.e. their data hasn't expired yet) and `false` otherwise. If there are no errors, `err` is `null`.
<a name="Options"></a>
## Options
Here are the default options:
```javascript
var options = {
verificationURL: 'http://example.com/email-verification/${URL}',
URLLength: 48,
// mongo-stuff
persistentUserModel: null,
tempUserModel: null,
tempUserCollection: 'temporary_users',
emailFieldName: 'email',
passwordFieldName: 'password',
URLFieldName: 'GENERATED_VERIFYING_URL',
expirationTime: 86400,
// emailing options
transportOptions: {
service: 'Gmail',
auth: {
user: 'user@gmail.com',
pass: 'password'
}
},
verifyMailOptions: {
from: 'Do Not Reply <user@gmail.com>',
subject: 'Confirm your account',
html: '<p>Please verify your account by clicking <a href="${URL}">this link</a>. If you are unable to do so, copy and ' +
'paste the following link into your browser:</p><p>${URL}</p>',
text: 'Please verify your account by clicking the following link, or by copying and pasting it into your browser: ${URL}'
},
shouldSendConfirmation: true,
confirmMailOptions: {
from: 'Do Not Reply <user@gmail.com>',
subject: 'Successfully verified!',
html: '<p>Your account has been successfully verified.</p>',
text: 'Your account has been successfully verified.'
},
hashingFunction: null,
}
```
- **verificationURL**: the URL for the user to click to verify their account. `${URL}` determines where the randomly generated part of the URL goes, and is needed. Required.
- **URLLength**: the length of the randomly-generated string. Must be a positive integer. Required.
- **persistentUserModel**: the Mongoose Model for the persistent user.
- **tempUserModel**: the Mongoose Model for the temporary user. you can generate the model by using `generateTempUserModel` and passing it the persistent User model you have defined, or you can define your own model in a separate file and pass it as an option in `configure` instead.
- **tempUserCollection**: the name of the MongoDB collection for temporary users.
- **emailFieldName**: the field name for the user's email. If the field is nested within another object(s), use dot notation to access it, e.g. `{local: {email: ...}}` would use `'local.email'`. Required.
- **passwordFieldName**: the field name for the user's password. If the field is nested within another object(s), use dot notation to access it (see above). Required.
- **URLFieldName**: the field name for the randomly-generated URL. Required.
- **expirationTime**: the amount of time that the temporary user will be kept in collection, measured in seconds. Must be a positive integer. Required.
- **transportOptions**: the options that will be passed to `nodemailer.createTransport`.
- **verifyMailOptions**: the options that will be passed to `nodemailer.createTransport({...}).sendMail` when sending an email for verification. You must include `${URL}` somewhere in the `html` and/or `text` fields to put the URL in these strings.
- **shouldSendConfirmation**: send an email upon the user verifiying their account to notify them of verification.
- **confirmMailOptions**: the options that will be passed to `nodemailer.createTransport({...}).sendMail` when sending an email to notify the user that their account has been verified. You must include `${URL}` somewhere in the `html` and/or `text` fields to put the URL in these strings.
- **hashingFunction**: the function that hashes passwords. Must take four parameters `password, tempUserData, insertTempUser, callback` and return `insertTempUser(hash, tempUserData, callback)`.
### Development
To beautify the code:
```
npm run format:main
npm run format:examples
npm run format:test
npm run format # runs all
```
To lint the code (will error if there are any warnings):
```
npm run lint:main
npm run lint:examples
npm run lint:test
npm run lint # runs all
```
To test:
```
npm test
```
### Acknowledgements
thanks to [Dakota St. Lauren](https://github.com/SaintDako) for starting this project
thanks to [Frank Cash](https://github.com/frankcash) for looking over the code and adding tests.
### license
ISC
================================================
FILE: examples/express/app/userModel.js
================================================
var mongoose = require('mongoose'),
bcrypt = require('bcryptjs');
var userSchema = mongoose.Schema({
email: String,
pw: String,
});
userSchema.methods.validPassword = function(password) {
return bcrypt.compareSync(password, this.pw);
};
module.exports = mongoose.model('real_users', userSchema);
================================================
FILE: examples/express/index.html
================================================
<html>
<head>
<script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
</head>
<body>
<p>
<input id="email-reg" type="text" placeholder="Email">
<input id="pw-reg" type="password" placeholder="Password">
<button onclick="register()">Register</button><br>
<span id="msg-reg"></span>
</p>
<hr>
<p>
<input id="email-resend" type="text" placeholder="Email">
<button onclick="resend()">Resend Verification Email</button><br>
<span id="msg-resend"></span>
</p>
<script type="text/javascript">
var register = function() {
var email = $("#email-reg").val(),
pw = $("#pw-reg").val();
$.ajax({
url: '/',
type: 'POST',
data: {email: email, pw: pw, type: 'register'}
}).then(function(data) {
$("#msg-reg").text(data.msg);
});
};
var resend = function() {
var email = $("#email-resend").val();
$.ajax({
url: '/',
type: 'POST',
data: {email: email, type: 'resend'}
}).then(function(data) {
$("#msg-resend").text(data.msg);
});
};
</script>
</body>
</html>
================================================
FILE: examples/express/server.js
================================================
var express = require('express'),
bodyParser = require('body-parser'),
app = express(),
mongoose = require('mongoose'),
bcrypt = require('bcryptjs'),
nev = require('../../index')(mongoose);
mongoose.connect('mongodb://localhost/YOUR_DB');
// our persistent user model
var User = require('./app/userModel');
// sync version of hashing function
var myHasher = function(password, tempUserData, insertTempUser, callback) {
var hash = bcrypt.hashSync(password, bcrypt.genSaltSync(8), null);
return insertTempUser(hash, tempUserData, callback);
};
// async version of hashing function
myHasher = function(password, tempUserData, insertTempUser, callback) {
bcrypt.genSalt(8, function(err, salt) {
bcrypt.hash(password, salt, function(err, hash) {
return insertTempUser(hash, tempUserData, callback);
});
});
};
// NEV configuration =====================
nev.configure({
persistentUserModel: User,
expirationTime: 600, // 10 minutes
verificationURL: 'http://localhost:8000/email-verification/${URL}',
transportOptions: {
service: 'Gmail',
auth: {
user: 'yoursupercoolemailyeah@gmail.com',
pass: 'yoursupersecurepassword'
}
},
hashingFunction: myHasher,
passwordFieldName: 'pw',
}, function(err, options) {
if (err) {
console.log(err);
return;
}
console.log('configured: ' + (typeof options === 'object'));
});
nev.generateTempUserModel(User, function(err, tempUserModel) {
if (err) {
console.log(err);
return;
}
console.log('generated temp user model: ' + (typeof tempUserModel === 'function'));
});
// Express stuff =========================
app.use(bodyParser.urlencoded());
app.get('/', function(req, res) {
res.sendFile('index.html', {
root: __dirname
});
});
app.post('/', function(req, res) {
var email = req.body.email;
// register button was clicked
if (req.body.type === 'register') {
var pw = req.body.pw;
var newUser = new User({
email: email,
pw: pw
});
nev.createTempUser(newUser, function(err, existingPersistentUser, newTempUser) {
if (err) {
return res.status(404).send('ERROR: creating temp user FAILED');
}
// user already exists in persistent collection
if (existingPersistentUser) {
return res.json({
msg: 'You have already signed up and confirmed your account. Did you forget your password?'
});
}
// new user created
if (newTempUser) {
var URL = newTempUser[nev.options.URLFieldName];
nev.sendVerificationEmail(email, URL, function(err, info) {
if (err) {
return res.status(404).send('ERROR: sending verification email FAILED');
}
res.json({
msg: 'An email has been sent to you. Please check it to verify your account.',
info: info
});
});
// user already exists in temporary collection!
} else {
res.json({
msg: 'You have already signed up. Please check your email to verify your account.'
});
}
});
// resend verification button was clicked
} else {
nev.resendVerificationEmail(email, function(err, userFound) {
if (err) {
return res.status(404).send('ERROR: resending verification email FAILED');
}
if (userFound) {
res.json({
msg: 'An email has been sent to you, yet again. Please check it to verify your account.'
});
} else {
res.json({
msg: 'Your verification code has expired. Please sign up again.'
});
}
});
}
});
// user accesses the link that is sent
app.get('/email-verification/:URL', function(req, res) {
var url = req.params.URL;
nev.confirmTempUser(url, function(err, user) {
if (user) {
nev.sendConfirmationEmail(user.email, function(err, info) {
if (err) {
return res.status(404).send('ERROR: sending confirmation email FAILED');
}
res.json({
msg: 'CONFIRMED!',
info: info
});
});
} else {
return res.status(404).send('ERROR: confirming temp user FAILED');
}
});
});
app.listen(8000);
console.log('Express & NEV example listening on 8000...');
================================================
FILE: examples/express/server_promisified.js
================================================
var express = require('express'),
bodyParser = require('body-parser'),
app = express(),
mongoose = require('mongoose'),
bcrypt = require('bcryptjs'),
Promise = require('bluebird'),
nev = Promise.promisifyAll(require('../../index')(mongoose));
mongoose.connect('mongodb://localhost/YOUR_DB');
function PromiseError(message) {
this.name = 'PromiseError';
this.message = message;
this.stack = (new Error()).stack;
}
PromiseError.prototype = Object.create(Error.prototype);
PromiseError.prototype.constructor = PromiseError;
// our persistent user model
var User = require('./app/userModel');
// sync version of hashing function
var myHasher = function(password, tempUserData, insertTempUser, callback) {
var hash = bcrypt.hashSync(password, bcrypt.genSaltSync(8), null);
return insertTempUser(hash, tempUserData, callback);
};
// async version of hashing function
myHasher = function(password, tempUserData, insertTempUser, callback) {
bcrypt.genSalt(8, function(err, salt) {
bcrypt.hash(password, salt, function(err, hash) {
return insertTempUser(hash, tempUserData, callback);
});
});
};
// NEV configuration =====================
nev.configureAsync({
persistentUserModel: User,
expirationTime: 600, // 10 minutes
verificationURL: 'http://localhost:9000/email-verification/${URL}',
transportOptions: {
service: 'Gmail',
auth: {
user: 'yoursupercoolemailyeah@gmail.com',
pass: 'yoursupersecurepassword'
}
},
hashingFunction: myHasher,
passwordFieldName: 'pw',
})
.then(function(options) {
console.log('configured: ' + (typeof options === 'object'));
return nev.generateTempUserModelAsync(User);
})
.then(function(tempUserModel) {
console.log('generated temp user model: ' + (typeof tempUserModel === 'function'));
})
.catch(function(err) {
console.log('ERROR!');
console.log(err);
});
// Express stuff =========================
app.use(bodyParser.urlencoded());
app.get('/', function(req, res) {
res.sendFile('index.html', {
root: __dirname
});
});
app.post('/', function(req, res) {
var email = req.body.email;
// register button was clicked
if (req.body.type === 'register') {
var pw = req.body.pw;
var newUser = new User({
email: email,
pw: pw
});
nev.createTempUserAsync(newUser)
.then(function(data) {
var existingPersistentUser = data[0],
newTempUser = data[1];
// user already exists in persistent collection
if (existingPersistentUser) {
res.json({
msg: 'You have already signed up and confirmed your account. Did you forget your password?'
});
throw new PromiseError('User already exists in persistent collection.');
}
if (newTempUser) {
var URL = newTempUser[nev.options.URLFieldName];
return nev.sendVerificationEmailAsync(email, URL);
// user already exists in temporary collection!
} else {
res.json({
msg: 'You have already signed up. Please check your email to verify your account.'
});
throw new PromiseError('User already exists in temporary collection.');
}
})
.then(function(info) {
res.json({
msg: 'An email has been sent to you. Please check it to verify your account.',
info: info
});
})
.catch(function(err) {
if (err.name !== 'PromiseError') {
return res.status(404).send('FAILED');
}
});
// resend verification button was clicked
} else {
nev.resendVerificationEmailAsync(email)
.then(function(userFound) {
if (userFound) {
res.json({
msg: 'An email has been sent to you, yet again. Please check it to verify your account.'
});
} else {
res.json({
msg: 'Your verification code has expired. Please sign up again.'
});
}
})
.catch(function() {
return res.status(404).send('ERROR: resending verification email FAILED');
});
}
});
// user accesses the link that is sent
app.get('/email-verification/:URL', function(req, res) {
var url = req.params.URL;
nev.confirmTempUserAsync(url)
.then(function(user) {
if (user) {
return nev.sendConfirmationEmailAsync(user.email);
} else {
res.json({
msg: 'Couldn\'t confirm user. Perhaps your code expired?'
});
throw new PromiseError('User could not be confirmed.');
}
})
.then(function(info) {
res.json({
msg: 'You have been confirmed!',
info: info
});
})
.catch(function(err) {
if (err.name !== 'PromiseError') {
res.status(404).send('FAILED');
}
});
});
app.listen(9000);
console.log('Express & NEV PROMISIFIED! example listening on 9000...');
================================================
FILE: index.js
================================================
'use strict';
var randtoken = require('rand-token'),
nodemailer = require('nodemailer');
module.exports = function(mongoose) {
var isPositiveInteger = function(x) {
return ((parseInt(x, 10) === x) && (x >= 0));
};
var createOptionError = function(optionName, optionValue, expectedType) {
return new TypeError('Expected ' + optionName + ' to be a ' + expectedType + ', got ' +
typeof optionValue);
};
/**
* Retrieve a nested value of an object given a string, using dot notation.
*
* @func getNestedValue
* @param {object} obj - object to retrieve the value from
* @param {string} path - path to value
* @param {string} def - default value to return if not found
*/
var getNestedValue = function(obj, path, def) {
path = path.split('.');
for (let i = 0, len = path.length; i < len; i++) {
if (!obj || typeof obj !== 'object') {
return def;
}
obj = obj[path[i]];
}
if (obj === undefined) {
return def;
}
return obj;
};
// default options
var options = {
verificationURL: 'http://example.com/email-verification/${URL}',
URLLength: 48,
// mongo-stuff
persistentUserModel: null,
tempUserModel: null,
tempUserCollection: 'temporary_users',
emailFieldName: 'email',
passwordFieldName: 'password',
URLFieldName: 'GENERATED_VERIFYING_URL',
expirationTime: 86400,
// emailing options
transportOptions: {
service: 'Gmail',
auth: {
user: 'user@gmail.com',
pass: 'password'
}
},
verifyMailOptions: {
from: 'Do Not Reply <user@gmail.com>',
subject: 'Confirm your account',
html: '<p>Please verify your account by clicking <a href="${URL}">this link</a>. If you are unable to do so, copy and ' +
'paste the following link into your browser:</p><p>${URL}</p>',
text: 'Please verify your account by clicking the following link, or by copying and pasting it into your browser: ${URL}'
},
verifySendMailCallback: function(err, info) {
if (err) {
throw err;
} else {
console.log(info.response);
}
},
shouldSendConfirmation: true,
confirmMailOptions: {
from: 'Do Not Reply <user@gmail.com>',
subject: 'Successfully verified!',
html: '<p>Your account has been successfully verified.</p>',
text: 'Your account has been successfully verified.'
},
confirmSendMailCallback: function(err, info) {
if (err) {
throw err;
} else {
console.log(info.response);
}
},
hashingFunction: null,
};
var transporter;
/**
* Modify the default configuration.
*
* @func configure
* @param {object} o - options to be changed
*/
var configure = function(optionsToConfigure, callback) {
for (let key in optionsToConfigure) {
if (optionsToConfigure.hasOwnProperty(key)) {
options[key] = optionsToConfigure[key];
}
}
transporter = nodemailer.createTransport(options.transportOptions);
var err;
if (typeof options.verificationURL !== 'string') {
err = err || createOptionError('verificationURL', options.verificationURL, 'string');
} else if (options.verificationURL.indexOf('${URL}') === -1) {
err = err || new Error('Verification URL does not contain ${URL}');
}
if (typeof options.URLLength !== 'number') {
err = err || createOptionError('URLLength', options.URLLength, 'number');
} else if (!isPositiveInteger(options.URLLength)) {
err = err || new Error('URLLength must be a positive integer');
}
if (typeof options.tempUserCollection !== 'string') {
err = err || createOptionError('tempUserCollection', options.tempUserCollection, 'string');
}
if (typeof options.emailFieldName !== 'string') {
err = err || createOptionError('emailFieldName', options.emailFieldName, 'string');
}
if (typeof options.passwordFieldName !== 'string') {
err = err || createOptionError('passwordFieldName', options.passwordFieldName, 'string');
}
if (typeof options.URLFieldName !== 'string') {
err = err || createOptionError('URLFieldName', options.URLFieldName, 'string');
}
if (typeof options.expirationTime !== 'number') {
err = err || createOptionError('expirationTime', options.expirationTime, 'number');
} else if (!isPositiveInteger(options.expirationTime)) {
err = err || new Error('expirationTime must be a positive integer');
}
if (err) {
return callback(err, null);
}
return callback(null, options);
};
/**
* Create a Mongoose Model for the temporary user, based off of the persistent
* User model, i.e. the temporary user inherits the persistent user. An
* additional field for the URL is created, as well as a TTL.
*
* @func generateTempUserModel
* @param {object} User - the persistent User model.
* @return {object} the temporary user model
*/
var generateTempUserModel = function(User, callback) {
if (!User) {
return callback(new TypeError('Persistent user model undefined.'), null);
}
var tempUserSchemaObject = {}, // a copy of the schema
tempUserSchema;
// copy over the attributes of the schema
Object.keys(User.schema.paths).forEach(function(field) {
tempUserSchemaObject[field] = User.schema.paths[field].options;
});
tempUserSchemaObject[options.URLFieldName] = String;
// create a TTL
tempUserSchemaObject.createdAt = {
type: Date,
expires: options.expirationTime.toString() + 's',
default: Date.now
};
tempUserSchema = mongoose.Schema(tempUserSchemaObject);
// copy over the methods of the schema
Object.keys(User.schema.methods).forEach(function(meth) { // tread lightly
tempUserSchema.methods[meth] = User.schema.methods[meth];
});
options.tempUserModel = mongoose.model(options.tempUserCollection, tempUserSchema);
return callback(null, mongoose.model(options.tempUserCollection));
};
/**
* Helper function for actually inserting the temporary user into the database.
*
* @func insertTempUser
* @param {string} password - the user's password, possibly hashed
* @param {object} tempUserData - the temporary user's data
* @param {function} callback - a callback function, which takes an error and the
* temporary user object as params
* @return {function} returns the callback function
*/
var insertTempUser = function(password, tempUserData, callback) {
// password may or may not be hashed
tempUserData[options.passwordFieldName] = password;
var newTempUser = new options.tempUserModel(tempUserData);
newTempUser.save(function(err, tempUser) {
if (err) {
return callback(err, null, null);
}
return callback(null, null, tempUser);
});
};
/**
* Attempt to create an instance of a temporary user based off of an instance of a
* persistent user. If user already exists in the temporary collection, passes null
* to the callback function; otherwise, passes the instance to the callback, with a
* randomly generated URL associated to it.
*
* @func createTempUser
* @param {object} user - an instance of the persistent User model
* @param {function} callback - a callback function that takes an error (if one exists),
* a persistent user (if it exists) and the new temporary user as arguments; if the
* temporary user already exists, then null is returned in its place
* @return {function} returns the callback function
*/
var createTempUser = function(user, callback) {
if (!options.tempUserModel) {
return callback(new TypeError('Temporary user model not defined. Either you forgot' +
'to generate one or you did not predefine one.'), null);
}
// create our mongoose query
var query = {};
if (options.emailFieldName.split('.').length > 1) {
var levels = options.emailFieldName.split('.');
query[levels[0]] = {};
var queryObj = query[levels[0]];
var userObj = user[levels[0]];
for (var i = 0; i < levels.length; i++) {
queryObj[levels[i + 1]] = {};
queryObj = queryObj[levels[i + 1]];
userObj = userObj[levels[i + 1]];
}
queryObj = userObj;
} else {
query[options.emailFieldName] = user[options.emailFieldName];
}
options.persistentUserModel.findOne(query, function(err, existingPersistentUser) {
if (err) {
return callback(err, null, null);
}
// user has already signed up and confirmed their account
if (existingPersistentUser) {
return callback(null, existingPersistentUser, null);
}
options.tempUserModel.findOne(query, function(err, existingTempUser) {
if (err) {
return callback(err, null, null);
}
// user has already signed up but not yet confirmed their account
if (existingTempUser) {
return callback(null, null, null);
} else {
var tempUserData = {};
// copy the credentials for the user
Object.keys(user._doc).forEach(function(field) {
tempUserData[field] = user[field];
});
tempUserData[options.URLFieldName] = randtoken.generate(options.URLLength);
if (options.hashingFunction) {
return options.hashingFunction(tempUserData[options.passwordFieldName], tempUserData,
insertTempUser, callback);
} else {
return insertTempUser(tempUserData[options.passwordFieldName], tempUserData, callback);
}
}
});
});
};
/**
* Send an email to the user requesting confirmation.
*
* @func sendVerificationEmail
* @param {string} email - the user's email address.
* @param {string} url - the unique url generated for the user.
* @param {function} callback - the callback to pass to Nodemailer's transporter
*/
var sendVerificationEmail = function(email, url, callback) {
var r = /\$\{URL\}/g;
// inject newly-created URL into the email's body and FIRE
// stringify --> parse is used to deep copy
var URL = options.verificationURL.replace(r, url),
mailOptions = JSON.parse(JSON.stringify(options.verifyMailOptions));
mailOptions.to = email;
mailOptions.html = mailOptions.html.replace(r, URL);
mailOptions.text = mailOptions.text.replace(r, URL);
if (!callback) {
callback = options.verifySendMailCallback;
}
transporter.sendMail(mailOptions, callback);
};
/**
* Send an email to the user requesting confirmation.
*
* @func sendConfirmationEmail
* @param {string} email - the user's email address.
* @param {function} callback - the callback to pass to Nodemailer's transporter
*/
var sendConfirmationEmail = function(email, callback) {
var mailOptions = JSON.parse(JSON.stringify(options.confirmMailOptions));
mailOptions.to = email;
if (!callback) {
callback = options.confirmSendMailCallback;
}
transporter.sendMail(mailOptions, callback);
};
/**
* Transfer a temporary user from the temporary collection to the persistent
* user collection, removing the URL assigned to it.
*
* @func confirmTempUser
* @param {string} url - the randomly generated URL assigned to a unique email
*/
var confirmTempUser = function(url, callback) {
var TempUser = options.tempUserModel,
query = {};
query[options.URLFieldName] = url;
TempUser.findOne(query, function(err, tempUserData) {
if (err) {
return callback(err, null);
}
// temp user is found (i.e. user accessed URL before their data expired)
if (tempUserData) {
// var userData = JSON.parse(JSON.stringify(tempUserData)),
var userData = {},
User = options.persistentUserModel,
user;
for (var property in tempUserData) userData[property] = tempUserData[property];
delete userData[options.URLFieldName];
user = new User(userData);
// save the temporary user to the persistent user collection
user.save(function(err, savedUser) {
if (err) {
return callback(err, null);
}
TempUser.remove(query, function(err) {
if (err) {
return callback(err, null);
}
if (options.shouldSendConfirmation) {
sendConfirmationEmail(savedUser[options.emailFieldName], null);
}
return callback(null, user);
});
});
// temp user is not found (i.e. user accessed URL after data expired, or something else...)
} else {
return callback(null, null);
}
});
};
/**
* Resend the verification email to the user given only their email.
*
* @func resendVerificationEmail
* @param {object} email - the user's email address
*/
var resendVerificationEmail = function(email, callback) {
var query = {};
query[options.emailFieldName] = email;
options.tempUserModel.findOne(query, function(err, tempUser) {
if (err) {
return callback(err, null);
}
// user found (i.e. user re-requested verification email before expiration)
if (tempUser) {
// generate new user token
tempUser[options.URLFieldName] = randtoken.generate(options.URLLength);
tempUser.save(function(err) {
if (err) {
return callback(err, null);
}
sendVerificationEmail(getNestedValue(tempUser, options.emailFieldName), tempUser[options.URLFieldName], function(err) {
if (err) {
return callback(err, null);
}
return callback(null, true);
});
});
} else {
return callback(null, false);
}
});
};
return {
options: options,
configure: configure,
generateTempUserModel: generateTempUserModel,
createTempUser: createTempUser,
confirmTempUser: confirmTempUser,
resendVerificationEmail: resendVerificationEmail,
sendConfirmationEmail: sendConfirmationEmail,
sendVerificationEmail: sendVerificationEmail,
};
};
================================================
FILE: package.json
================================================
{
"name": "email-verification",
"version": "0.4.6",
"description": "Verify email sign-up using MongoDB.",
"main": "index.js",
"scripts": {
"format:examples": "js-beautify -r examples/**/*.js",
"format:main": "js-beautify -r index.js",
"format:test": "js-beautify -s 2 -r test/*.js",
"format": "npm run format:main && npm run format:examples && npm run format:test",
"lint:examples": "jshint --reporter=node_modules/jshint-stylish examples/**/*.js",
"lint:main": "jshint --reporter=node_modules/jshint-stylish index.js",
"lint:test": "jshint --reporter=node_modules/jshint-stylish test/*.js",
"lint": "npm run lint:main && npm run lint:examples && npm run lint:test",
"test": "mocha"
},
"repository": {
"type": "git",
"url": "https://github.com/whitef0x0/node-email-verification"
},
"keywords": [
"mongodb",
"auth",
"authentication",
"email"
],
"author": "Dakota St. Laurent <hello@saintdako.com> (http://saintdako.com/)",
"contributors": [
{
"name": "David Baldwynn",
"email": "polydaic@gmail.com",
"url": "https://github.com/whitef0x0"
},
{
"name": "Frank Cash",
"email": "cashc@acm.org",
"url": "https://github.com/frankcash"
}
],
"license": "ISC",
"bugs": {
"url": "https://github.com/whitef0x0/node-email-verification/issues"
},
"homepage": "http://email-verification.xyz/",
"dependencies": {
"mongoose": "^4.9.9",
"nodemailer": "^1.3.0",
"rand-token": "^0.2.1"
},
"devDependencies": {
"async": "^2.4.0",
"bcryptjs": "^2.3.0",
"body-parser": "^1.9.3",
"chai": "*",
"express": "^4.10.4",
"js-beautify": "^1.5.10",
"jshint": "*",
"jshint-stylish": "*",
"mocha": "*",
"nodemailer-stub-transport": "^1.0.0",
"bluebird": "*"
}
}
================================================
FILE: test/tests.js
================================================
var should = require('chai').should();
var mongoose = require('mongoose');
var nev = require('../index')(mongoose);
var stubTransport = require('nodemailer-stub-transport');
var user = require('../examples/express/app/userModel'); // sample user schema
mongoose.connect('mongodb://localhost/test_database'); // needed for testing
describe('config & set up tests', function() {
var tempUserModel;
it('Generates a temp user model', function(done) {
nev.generateTempUserModel(user, function(err, generatedTempUserModel) {
tempUserModel = generatedTempUserModel;
done();
});
});
describe('Test configuration error throwing', function() {
var defaultOptions;
before(function() {
defaultOptions = JSON.parse(JSON.stringify(nev.options));
});
var tests = [{
field: 'verificationURL',
wrongValue: 100,
reason: 'type'
},
{
field: 'verificationURL',
wrongValue: 'someurl',
reason: 'value'
},
{
field: 'URLLength',
wrongValue: 'str',
reason: 'type'
},
{
field: 'URLLength',
wrongValue: -20,
reason: 'value'
},
{
field: 'URLLength',
wrongValue: 5.5,
reason: 'value'
},
{
field: 'tempUserCollection',
wrongValue: null,
reason: 'type'
},
{
field: 'emailFieldName',
wrongValue: [],
reason: 'type'
},
{
field: 'passwordFieldName',
wrongValue: {},
reason: 'type'
},
{
field: 'URLFieldName',
wrongValue: 5.5,
reason: 'type'
},
{
field: 'expirationTime',
wrongValue: '100',
reason: 'type'
},
{
field: 'expirationTime',
wrongValue: -42,
reason: 'value'
},
{
field: 'expirationTime',
wrongValue: 4.2,
reason: 'value'
},
];
tests.forEach(function(test) {
it('should throw an error for invalid ' + test.field + ' ' + test.reason, function(done) {
var optionsToConfigure = {};
optionsToConfigure[test.field] = test.wrongValue;
nev.configure(optionsToConfigure, function(err, options) {
should.exist(err);
should.not.exist(options);
done();
});
});
});
after(function(done) {
var newOptions = JSON.parse(JSON.stringify(defaultOptions));
newOptions.tempUserModel = tempUserModel;
newOptions.transportOptions = stubTransport();
newOptions.persistentUserModel = user;
newOptions.passwordFieldName = 'pw';
nev.configure(newOptions, done);
});
});
});
describe('MongoDB tests', function() {
var newUser, newUserURL;
before(function(done) {
newUser = new user({
email: 'foobar@fizzbuzz.com',
pw: 'pass'
});
done();
});
it('should create a temporary user (createTempUser())', function(done) {
nev.createTempUser(newUser, function(err, existingPersistentUser, newTempUser) {
should.not.exist(err);
should.not.exist(existingPersistentUser);
should.exist(newTempUser);
nev.options.tempUserModel.findOne({
email: newUser.email
}).exec(function(err, result) {
should.not.exist(err);
should.exist(result);
result.should.have.property('email').with.length(newUser.email.length);
result.should.have.property('pw').with.length(4);
newUserURL = result.GENERATED_VERIFYING_URL;
done();
});
});
});
it('should put the temporary user into the persistent collection (confirmTempUser())', function(done) {
nev.confirmTempUser(newUserURL, function(err, newUser) {
should.not.exist(err);
should.exist(newUser);
user.findOne({
email: newUser.email
}).exec(function(err, result) {
should.not.exist(err);
should.exist(result);
result.should.have.property('email').with.length(newUser.email.length);
result.should.have.property('pw').with.length(4);
done();
});
});
});
after(function(done) {
user.remove().exec(done);
});
});
gitextract_zjuzp3q7/
├── .github/
│ └── PULL_REQUEST_TEMPLATE
├── .gitignore
├── .jshintrc
├── .npmignore
├── .travis.yml
├── README.md
├── examples/
│ └── express/
│ ├── app/
│ │ └── userModel.js
│ ├── index.html
│ ├── server.js
│ └── server_promisified.js
├── index.js
├── package.json
└── test/
└── tests.js
SYMBOL INDEX (1 symbols across 1 files)
FILE: examples/express/server_promisified.js
function PromiseError (line 10) | function PromiseError(message) {
Condensed preview — 13 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (59K chars).
[
{
"path": ".github/PULL_REQUEST_TEMPLATE",
"chars": 522,
"preview": "<!-- Fill out this template to explain your pull request. -->\n\n#### What's in this pull request?\n# Fixed confirmTempUser"
},
{
"path": ".gitignore",
"chars": 116,
"preview": ".idea/\nnode_modules\n*.log\n*.sublime-*\nTEST.js\ntempuser.js\n\n# compiled markdown\nREADME.html\n.DS_Store\ndevelopment.js\n"
},
{
"path": ".jshintrc",
"chars": 5833,
"preview": "{\n \"maxerr\" : 100, // {int} Maximum error before stopping\n\n // Enforcing\n \"bitwise\" : false,"
},
{
"path": ".npmignore",
"chars": 52,
"preview": "examples\n*.sublime-*\n*.log\nREADME.html\ntest\n.github\n"
},
{
"path": ".travis.yml",
"chars": 86,
"preview": "language: node_js\nnode_js:\n - \"7\"\n - \"6\"\n - \"6.1\"\n - \"5.11\"\nservices:\n - mongodb\n"
},
{
"path": "README.md",
"chars": 16034,
"preview": "# <img src=\"https://github.com/whitef0x0/node-email-verification/raw/master/design/logo.png\" data-canonical-src=\"https:/"
},
{
"path": "examples/express/app/userModel.js",
"chars": 307,
"preview": "var mongoose = require('mongoose'),\n bcrypt = require('bcryptjs');\n\nvar userSchema = mongoose.Schema({\n email: String,"
},
{
"path": "examples/express/index.html",
"chars": 1217,
"preview": "<html>\n <head>\n <script src=\"//code.jquery.com/jquery-1.11.0.min.js\"></script>\n </head>\n <body>\n <p>\n <inp"
},
{
"path": "examples/express/server.js",
"chars": 4840,
"preview": "var express = require('express'),\n bodyParser = require('body-parser'),\n app = express(),\n mongoose = require('"
},
{
"path": "examples/express/server_promisified.js",
"chars": 5676,
"preview": "var express = require('express'),\n bodyParser = require('body-parser'),\n app = express(),\n mongoose = require('"
},
{
"path": "index.js",
"chars": 16175,
"preview": "'use strict';\n\nvar randtoken = require('rand-token'),\n nodemailer = require('nodemailer');\n\nmodule.exports = function"
},
{
"path": "package.json",
"chars": 1842,
"preview": "{\n \"name\": \"email-verification\",\n \"version\": \"0.4.6\",\n \"description\": \"Verify email sign-up using MongoDB.\",\n \"main\""
},
{
"path": "test/tests.js",
"chars": 4237,
"preview": "var should = require('chai').should();\nvar mongoose = require('mongoose');\nvar nev = require('../index')(mongoose);\nvar "
}
]
About this extraction
This page contains the full source code of the whitef0x0/node-email-verification GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 13 files (55.6 KB), approximately 13.1k tokens, and a symbol index with 1 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.