Skip to content
forked from mscdex/node-imap

An IMAP client module for node.js.

License

Notifications You must be signed in to change notification settings

wavify/node-imap

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Description

node-imap is an IMAP module for node.js that provides an asynchronous interface for communicating with an IMAP mail server.

This module does not perform any magic such as auto-decoding of messages/attachments or parsing of email addresses (node-imap leaves all mail header values as-is). If you are in need of this kind of extra functionality, check out andris9's mimelib module. Also check out his mailparser module, which comes in handy after you fetch() a 'full' raw email message with this module.

Requirements

  • node.js -- v0.4.0 or newer
  • An IMAP server -- tested with gmail

Installation

npm install imap

Example

  • This example fetches the 'date', 'from', 'to', 'subject' message headers and the message structure of all unread messages in the Inbox since May 20, 2010:
  var util = require('util'),
      ImapConnection = require('imap').ImapConnection;
      
  var imap = new ImapConnection({
        username: 'mygmailname@gmail.com',
        password: 'mygmailpassword',
        host: 'imap.gmail.com',
        port: 993,
        secure: true
      });

  function show(obj) {
    return util.inspect(obj, false, Infinity);
  }

  function die(err) {
    console.log('Uh oh: ' + err);
    process.exit(1);
  }

  function openInbox(cb) {
    imap.connect(function(err) {
      if (err) die(err);
      imap.openBox('INBOX', false, cb);
    });
  }

  openInbox(function(err, mailbox) {
    if (err) die(err);
    imap.search([ 'UNSEEN', ['SINCE', 'May 20, 2010'] ], function(err, results) {
      if (err) die(err);
      var fetch = imap.fetch(results, {
        request: {
          headers: ['from', 'to', 'subject', 'date']
        }
      });
      fetch.on('message', function(msg) {
        console.log('Got a message with sequence number ' + msg.seqno);
        msg.on('end', function() {
          // msg.headers is now an object containing the requested headers ...
          console.log('Finished message. Headers ' + show(msg.headers));
        });
      });
      fetch.on('end', function() {
        console.log('Done fetching all messages!');
        imap.logout();
      });
    });
  });
  • Here is a modified version of the first example that retrieves the 'from' header and buffers the entire body of the newest message:
  // using the functions and variables already defined in the first example ...

  openInbox(function(err, mailbox) {
    if (err) die(err);
    var fetch = imap.seq.fetch(mailbox.messages.total + ':*',
                               { request: {
                                   headers: ['from'],
                                   body: true,
                                   struct: false } });
    fetch.on('message', function(msg) {
      console.log('Got a message with sequence number ' + msg.seqno);
      var body = '';
      msg.on('data', function(chunk) {
        body += chunk.toString('utf8');
      });
      msg.on('end', function() {
        // msg.headers is now an object containing the requested header(s) ...
        console.log('Finished message.');
        console.log('UID: ' + msg.uid);
        console.log('Flags: ' + msg.flags);
        console.log('Date: ' + msg.date);
        console.log('From: ' + msg.headers.from[0]);
        console.log('Body: ' + body);
      });
    });
    fetch.on('end', function() {
      console.log('Done fetching all messages!');
      imap.logout();
    });
  });
  • Here is a modified version of the first example that retrieves all (parsed) headers and writes the entire body of each message to a file:
  // using the functions and variables already defined in the first example ...

  var fs = require('fs'), fileStream;

  openInbox(function(err, mailbox) {
    if (err) die(err);
    imap.search([ 'UNSEEN', ['SINCE', 'May 20, 2010'] ], function(err, results) {
      if (err) die(err);
      var fetch = imap.fetch(results, {
        request: {
          headers: true,
          body: true
        }
      });
      fetch.on('message', function(msg) {
        console.log('Got a message with sequence number ' + msg.seqno);
        fileStream = fs.createWriteStream('msg-' + msg.seqno + '-body.txt');
        msg.on('data', function(chunk) {
          fileStream.write(chunk);
        });
        msg.on('end', function() {
          fileStream.end();
          // msg.headers is now an object containing the requested headers ...
          console.log('Finished message. Headers: ' + show(msg.headers));
        });
      });
      fetch.on('end', function() {
        console.log('Done fetching all messages!');
        imap.logout();
      });
    });
  });
  • Here is a modified version of the first example that retrieves and writes entire raw messages to files (no parsed headers):
  // using the functions and variables already defined in the first example ...

  var fs = require('fs'), fileStream;

  openInbox(function(err, mailbox) {
    if (err) die(err);
    imap.search([ 'UNSEEN', ['SINCE', 'May 20, 2010'] ], function(err, results) {
      if (err) die(err);
      var fetch = imap.fetch(results, {
        request: {
          headers: false,
          body: 'full'
        }
      });
      fetch.on('message', function(msg) {
        console.log('Got a message with sequence number ' + msg.seqno);
        fileStream = fs.createWriteStream('msg-' + msg.seqno + '-raw.txt');
        msg.on('data', function(chunk) {
          fileStream.write(chunk);
        });
        msg.on('end', function() {
          fileStream.end();
        });
      });
      fetch.on('end', function() {
        console.log('Done fetching all messages!');
        imap.logout();
      });
    });
  });

API

node-imap exposes one object: ImapConnection.

Data types

  • Box is an object representing the currently open mailbox, and has the following properties:
    • name - A string containing the name of this mailbox.
    • validity - A string containing a number that indicates whether the message UIDs in this mailbox have changed or not. In other words, as long as this value does not change on future openings of this mailbox, any cached message UIDs for this mailbox are still valid.
    • permFlags - An array containing the flags that can be permanently added/removed to/from messages in this mailbox.
    • messages - An object containing properties about message counts for this mailbox.
      • total - An Integer representing total number of messages in this mailbox.
      • new - An Integer representing the number of new (unread) messages in this mailbox.
  • ImapMessage is an object representing an email message. It consists of:
    • Properties:
      • seqno - An Integer that designates this message's sequence number. This number changes when messages with smaller sequence numbers are deleted for example (see the ImapConnection's 'deleted' event). This value is always available immediately.
      • uid - An Integer that uniquely identifies this message (within its mailbox).
      • flags - An array containing the flags currently set on this message.
      • date - A string containing the internal server date for the message (always represented in GMT?)
      • headers - An object containing the headers of the message, if headers were requested when calling fetch(). Note: Duplicate headers are dealt with by storing the duplicated values in an array keyed on the header name (e.g. { to: ['foo@bar.com', 'bar@baz.com'] }).
      • structure - An array containing the structure of the message, if the structure was requested when calling fetch(). See below for an explanation of the format of this property.
    • Events:
      • data(<Buffer>chunk) - Emitted for each message body chunk if a message body is being fetched
      • end() - Emitted when the fetch is complete for this message and its properties
  • ImapFetch is an object that emits these events:
    • message(<ImapMessage>msg) - Emitted for each message resulting from a fetch request
    • end() - Emitted when the fetch request is complete

A message structure with multiple parts might look something like the following:

  [ { type: 'mixed'
    , params: { boundary: '000e0cd294e80dc84c0475bf339d' }
    , disposition: null
    , language: null
    , location: null
    }
  , [ { type: 'alternative'
      , params: { boundary: '000e0cd294e80dc83c0475bf339b' }
      , disposition: null
      , language: null
      }
    , [ { partID: '1.1'
        , type: 'text'
        , subtype: 'plain'
        , params: { charset: 'ISO-8859-1' }
        , id: null
        , description: null
        , encoding: '7BIT'
        , size: 935
        , lines: 46
        , md5: null
        , disposition: null
        , language: null
        }
      ]
    , [ { partID: '1.2'
        , type: 'text'
        , subtype: 'html'
        , params: { charset: 'ISO-8859-1' }
        , id: null
        , description: null
        , encoding: 'QUOTED-PRINTABLE'
        , size: 1962
        , lines: 33
        , md5: null
        , disposition: null
        , language: null
        }
      ]
    ]
  , [ { partID: '2'
      , type: 'application'
      , subtype: 'octet-stream'
      , params: { name: 'somefile' }
      , id: null
      , description: null
      , encoding: 'BASE64'
      , size: 98
      , lines: null
      , md5: null
      , disposition:
         { type: 'attachment'
         , params: { filename: 'somefile' }
         }
      , language: null
      , location: null
      }
    ]
  ]

The above structure describes a message having both an attachment and two forms of the message body (plain text and HTML). Each message part is identified by a partID which is used when you want to fetch the content of that part (see fetch()).

The structure of a message with only one part will simply look something like this:

  [ { partID: '1'
      , type: 'text'
      , subtype: 'plain'
      , params: { charset: 'ISO-8859-1' }
      , id: null
      , description: null
      , encoding: '7BIT'
      , size: 935
      , lines: 46
      , md5: null
      , disposition: null
      , language: null
    }
  ]

Therefore, an easy way to check for a multipart message is to check if the structure length is >1.

Lastly, here are the system flags defined by the IMAP spec (that may be added/removed to/from messages):

  • Seen - Message has been read
  • Answered - Message has been answered
  • Flagged - Message is "flagged" for urgent/special attention
  • Deleted - Message is "deleted" for removal
  • Draft - Message has not completed composition (marked as a draft).

It should be noted however that the IMAP server can limit which flags can be permanently modified for any given message. If in doubt, check the mailbox's permFlags array first. Additional custom flags may be provided by the server. If available, these will also be listed in the mailbox's permFlags array.

ImapConnection Events

  • alert(<string>alertMsg) - Fires when the server issues an alert (e.g. "the server is going down for maintenance").

  • mail(<integer>numNewMsgs) - Fires when new mail arrives in the currently open mailbox.

  • deleted(<integer>seqno) - Fires when a message is deleted from another IMAP connection's session. The callback's argument is the sequence number (instead of the unique UID) of the message that was deleted. If you are caching sequence numbers, all sequence numbers higher than this value MUST be decremented by 1 in order to stay synchronized with the server and to keep the continuity.

  • msgupdate(<ImapMessage>msg) - Fires when a message's flags have changed, generally from another IMAP connection's session. With that in mind, the only available ImapMessage properties in this case will almost always only be 'seqno' and 'flags' (no 'data' or 'end' events will be emitted on the object).

  • close(<boolean>hadError) - Fires when the connection is completely closed.

  • end() - Fires when the connection is ended.

  • error(<Error>err) - Fires when an exception/error occurs.

ImapConnection Properties

  • capabilities - <array> - Contains the IMAP capabilities of the server.

  • delimiter - <string> - The (top-level) mailbox hierarchy delimiter. If the server does not support mailbox hierarchies and only a flat list, this value will be false.

  • namespaces - <object> - Contains information about each namespace type (if supported by the server) with the following properties:

    • personal - <array> - Mailboxes that belong to the logged in user

    • other - <array> - Mailboxes that belong to other users that the logged in user has access to

    • shared - <array> - Mailboxes that are accessible by any logged in user

    There should always be at least one entry (although the IMAP spec allows for more, it doesn't seem to be very common) in the personal namespace list, with a blank namespace prefix. Each property's array contains objects of the following format (with example values):

  { prefix: '' // A string containing the prefix to use to access mailboxes in this namespace
  , delimiter: '/' // A string containing the hierarchy delimiter for this namespace, or boolean false
              //  for a flat namespace with no hierarchy
  , extensions: [ // An array of namespace extensions supported by this namespace, or null if none
                  // are specified
        { name: 'X-FOO-BAR' // A string indicating the extension name
        , params: [ 'BAZ' ] // An array of strings containing the parameters for this extension,
                            // or null if none are specified
        }
    ]
  }

ImapConnection Functions

Note: Message UID ranges are not guaranteed to be contiguous.

  • (constructor)([<object>config]) - ImapConnection - Creates and returns a new instance of ImapConnection using the specified configuration object. Valid config properties are:

    • username - <string> - Username for plain-text authentication.

    • password - <string> - Password for plain-text authentication.

    • xoauth - <string> - OAuth token for OAuth authentication for servers that support it.

    • host - <string> - Hostname or IP address of the IMAP server. Default: "localhost"

    • port - <integer> - Port number of the IMAP server. Default: 143

    • secure - <boolean> - Use SSL/TLS? Default: false

    • connTimeout - <integer> - Number of milliseconds to wait for a connection to be established. Default: 10000

    • debug - <function> - If set, the function will be called with one argument, a string containing some debug info Default:

  • connect(<function>callback) - (void) - Attempts to connect and log into the IMAP server. The callback has one parameter: the error (falsey if none).

  • logout(<function>callback) - (void) - Closes the connection to the server.

  • openBox(<string>mailboxName[, <boolean>openReadOnly=false], <function>callback) - (void) - Opens a specific mailbox that exists on the server. mailboxName should include any necessary prefix/path. The callback has two parameters: the error (falsey if none), and the Box object containing information about the newly opened mailbox.

  • closeBox(<function>callback) - (void) - Closes the currently open mailbox. Any messages marked as Deleted in the mailbox will be removed if the mailbox was NOT opened in read-only mode. Additionally, logging out or opening another mailbox without closing the current one first will NOT cause deleted messages to be removed. The callback has one parameter: the error (falsey if none).

  • addBox(<string>mailboxName, <function>callback) - (void) - Creates a new mailbox on the server. mailboxName should include any necessary prefix/path. The callback has one parameter: the error (falsey if none).

  • delBox(<string>mailboxName, <function>callback) - (void) - Removes a specific mailbox that exists on the server. mailboxName should including any necessary prefix/path. The callback has one parameter: the error (falsey if none).

  • renameBox(<string>oldMailboxName, <string>newMailboxName, <function>callback) - (void) - Renames a specific mailbox that exists on the server. Both oldMailboxName and newMailboxName should include any necessary prefix/path. The callback has two parameters: the error (falsey if none), and the Box object containing information about the newly renamed mailbox. Note: Renaming the 'INBOX' mailbox will instead cause all messages in 'INBOX' to be moved to the new mailbox.

  • status(<string>mailboxName, <function>callback) - (void) - Fetches information about a mailbox other than the one currently open. The callback has two parameters: the error (falsey if none), and the Box object containing information about the specific mailbox. Note: There is no guarantee that this will be a fast operation on the server. Also, do not call this on the currently open mailbox.

  • getBoxes([<string>nsPrefix,] <function>callback) - (void) - Obtains the full list of mailboxes. If nsPrefix is not specified, the main personal namespace is used. The callback has two parameters: the error (falsey if none), and an object with the following format (with example values):

  { INBOX: // mailbox name
     { attribs: [] // mailbox attributes. An attribute of 'NOSELECT' indicates the mailbox cannot
                   // be opened
     , delimiter: '/' // hierarchy delimiter for accessing this mailbox's direct children.
     , children: null // an object containing another structure similar in format to this top level,
                      // otherwise null if no children
     , parent: null // pointer to parent mailbox, null if at the top level
     }
  , Work:
     { attribs: []
     , delimiter: '/'
     , children: null
     , parent: null
     }
  , '[Gmail]':
     { attribs: [ 'NOSELECT' ]
     , delimiter: '/'
     , children:
        { 'All Mail':
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        , Drafts:
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        , 'Sent Mail':
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        , Spam:
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        , Starred:
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        , Trash:
           { attribs: []
           , delimiter: '/'
           , children: null
           , parent: [Circular]
           }
        }
     , parent: null
     }
  }
  • removeDeleted(<function>callback) - (void) - Permanently removes (EXPUNGEs) all messages flagged as Deleted in the currently open mailbox. The callback has one parameter: the error (falsey if none). Note: At least on Gmail, performing this operation with any currently open mailbox that is not the Spam or Trash mailbox will merely archive any messages marked as Deleted (by moving them to the 'All Mail' mailbox).

  • append(<mixed>msgData, [<object>options,] <function>callback) - (void) - Appends a message to selected mailbox. msgData is a string or Buffer containing an RFC-822 compatible MIME message. Valid options are:

    • mailbox - <string> - The name of the mailbox to append the message to. Default: the currently open mailbox

    • flags - <mixed> - A single flag (e.g. 'Seen') or an array of flags (e.g. ['Seen', 'Flagged']) to append to the message. Default: (no flags)

    • date - <date> - What to use for message arrival date/time. Default: (current date/time)

    The callback has one parameter: the error (falsey if none).

All functions below have sequence number-based counterparts that can be accessed by using the 'seq' namespace of the imap connection's instance (e.g. conn.seq.search() returns sequence number(s) instead of UIDs, conn.seq.fetch() fetches by sequence number(s) instead of UIDs, etc):

  • search(<array>criteria, <function>callback) - (void) - Searches the currently open mailbox for messages using given criteria. criteria is a list describing what you want to find. For criteria types that require arguments, use an array instead of just the string criteria type name (e.g. ['FROM', 'foo@bar.com']). Prefix criteria types with an "!" to negate.

    • The following message flags are valid types that do not have arguments:

      • 'ALL' - All messages.

      • 'ANSWERED' - Messages with the Answered flag set.

      • 'DELETED' - Messages with the Deleted flag set.

      • 'DRAFT' - Messages with the Draft flag set.

      • 'FLAGGED' - Messages with the Flagged flag set.

      • 'NEW' - Messages that have the Recent flag set but not the Seen flag.

      • 'SEEN' - Messages that have the Seen flag set.

      • 'RECENT' - Messages that have the Recent flag set.

      • 'OLD' - Messages that do not have the Recent flag set. This is functionally equivalent to "!RECENT" (as opposed to "!NEW").

      • 'UNANSWERED' - Messages that do not have the Answered flag set.

      • 'UNDELETED' - Messages that do not have the Deleted flag set.

      • 'UNDRAFT' - Messages that do not have the Draft flag set.

      • 'UNFLAGGED' - Messages that do not have the Flagged flag set.

      • 'UNSEEN' - Messages that do not have the Seen flag set.

    • The following are valid types that require string value(s):

      • 'BCC' - Messages that contain the specified string in the BCC field.

      • 'CC' - Messages that contain the specified string in the CC field.

      • 'FROM' - Messages that contain the specified string in the FROM field.

      • 'SUBJECT' - Messages that contain the specified string in the SUBJECT field.

      • 'TO' - Messages that contain the specified string in the TO field.

      • 'BODY' - Messages that contain the specified string in the message body.

      • 'TEXT' - Messages that contain the specified string in the header OR the message body.

      • 'KEYWORD' - Messages with the specified keyword set.

      • 'HEADER' - Requires two string values, with the first being the header name and the second being the value to search for. If this second string is empty, all messages that contain the given header name will be returned.

    • The following are valid types that require a string parseable by JavaScript's Date object OR a Date instance:

      • 'BEFORE' - Messages whose internal date (disregarding time and timezone) is earlier than the specified date.

      • 'ON' - Messages whose internal date (disregarding time and timezone) is within the specified date.

      • 'SINCE' - Messages whose internal date (disregarding time and timezone) is within or later than the specified date.

      • 'SENTBEFORE' - Messages whose Date header (disregarding time and timezone) is earlier than the specified date.

      • 'SENTON' - Messages whose Date header (disregarding time and timezone) is within the specified date.

      • 'SENTSINCE' - Messages whose Date header (disregarding time and timezone) is within or later than the specified date.

    • The following are valid types that require one Integer value:

      • 'LARGER' - Messages with a size larger than the specified number of bytes.

      • 'SMALLER' - Messages with a size smaller than the specified number of bytes.

    • The following are valid criterion that require one or more Integer values:

      • 'UID' - Messages with UIDs corresponding to the specified UID set. Ranges are permitted (e.g. '2504:2507' or '*' or '2504:*').
    • Note #1: For the UID-based search (i.e. "conn.search()"), you can retrieve the UIDs for sequence numbers by just supplying an array of sequence numbers and/or ranges as a criteria (e.g. [ '24:29', 19, '66:*' ]).

    • Note #2: By default, all criterion are ANDed together. You can use the special 'OR' on two criterion to find messages matching either search criteria (see example below).

    criteria examples:

    • Unread messages since April 20, 2010: [ 'UNSEEN', ['SINCE', 'April 20, 2010'] ]

    • Messages that are EITHER unread OR are dated April 20, 2010 or later, you could use: [ ['OR', 'UNSEEN', ['SINCE', 'April 20, 2010'] ] ]

    • All messages that have 'node-imap' in the subject header: [ ['HEADER', 'SUBJECT', 'node-imap'] ]

    • All messages that do not have 'node-imap' in the subject header: [ ['!HEADER', 'SUBJECT', 'node-imap'] ]

    The callback has two parameters: the error (falsey if none), and an array containing the message UIDs matching the search criteria.

  • fetch(<mixed>source, <object>options) - ImapFetch - Fetches message(s) in the currently open mailbox. source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. Note: currently you can only fetch headers AND a body when body is set to true (partID and byte ranges not supported yet when also requesting headers). Valid options are:

    • markSeen - <boolean> - Mark message(s) as read when fetched. Default: false

    • request - <object> - What to fetch. Valid options are:

      • struct - <boolean> - Fetch the message structure. Default: true

      • headers - <mixed> - Boolean true fetches all message headers and an array of header names retrieves only those headers. Default: true

      • body - <mixed> - Boolean true fetches the entire raw message body. A string containing a valid partID (see FetchResult's structure property) fetches the entire body of that particular part. The string 'full' fetches the entire (unparsed) email message, including the headers. An array can be given to specify a byte range of the content, where the first value is boolean true or a partID and the second value is the byte range. For example, to fetch the first 500 bytes: '0-500'. Default: false

  • copy(<mixed>source, <string>mailboxName, <function>callback) - (void) - Copies message(s) in the currently open mailbox to another mailbox. source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. The callback has one parameter: the error (falsey if none).

  • move(<mixed>source, <string>mailboxName, <function>callback) - (void) - Moves message(s) in the currently open mailbox to another mailbox. source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. The callback has one parameter: the error (falsey if none). Note: The message(s) in the destination mailbox will have a new message UID.

  • addFlags(<mixed>source, <mixed>flags, <function>callback) - (void) - Adds flag(s) to message(s). source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. flags is either a single flag or an array of flags. The callback has one parameter: the error (falsey if none).

  • delFlags(<mixed>source, <mixed>flags, <function>callback) - (void) - Removes flag(s) from message(s). source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. flags is either a single flag or an array of flags. The callback has one parameter: the error (falsey if none).

  • addKeywords(<mixed>source, <mixed>keywords, <function>callback) - (void) - Adds keyword(s) to message(s). source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. keywords is either a single keyword or an array of keywords. The callback has one parameter: the error (falsey if none).

  • delKeywords(<mixed>source, <mixed>keywords, <function>callback) - (void) - Removes keyword(s) from message(s). source can be a message UID, a message UID range (e.g. '2504:2507' or '*' or '2504:*'), or an array of message UIDs and/or message UID ranges. keywords is either a single keyword or an array of keywords. The callback has one parameter: the error (falsey if none).

Extensions Supported

  • Gmail

    • Server capability: X-GM-EXT-1

    • search() criteria extensions

      • X-GM-RAW: string value which allows you to use Gmail's web interface search syntax, such as: "has:attachment in:unread"

      • X-GM-THRID: allows you to search for a specific conversation/thread id which is associated with groups of messages

      • X-GM-MSGID: allows you to search for a specific message given its account-wide unique id

      • X-GM-LABELS: string value which allows you to search for specific messages that have the given label applied

    • fetch() will automatically retrieve the thread id, unique message id, and labels with the message properties being 'x-gm-thrid', 'x-gm-msgid', 'x-gm-labels' respectively

TODO

Several things not yet implemented in no particular order:

About

An IMAP client module for node.js.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • JavaScript 100.0%