Part of the PurelyManage series.

The Problem

Migrating an organization’s email means moving every mailbox from the old provider into PurelyMail. Each mailbox requires the source IMAP credentials: the email address and password the user logs in with on the old system.

The naive way to collect these is to email everyone a spreadsheet and ask them to fill in their passwords. That spreadsheet then sits in someone’s inbox or a shared drive, plaintext, accessible to anyone who can read it. It is also error-prone: users mistype passwords, the admin has no way to know if a credential is correct until the migration job fails.

The credential gathering system in PurelyManage is built to solve both problems. Users submit their own credentials through a form that validates the IMAP connection before saving anything. Credentials are encrypted at rest from the moment they are stored. The admin never sees any password in plaintext.

Campaigns

An admin creates a gather campaign in the Migration page. Creating one requires two things: a source IMAP server (from the configured server list) and an expiry duration (3, 7, 14, or 30 days).

The backend generates a 64-character random hex token:

const token = randomBytes(32).toString('hex')

This token becomes the public URL: /gather/<token>. It is the only thing an admin needs to share with users. There are no login credentials to distribute, no app to install. Users open the link in a browser and fill in a form.

Campaigns can be deactivated at any time. A deactivated campaign returns HTTP 410 on all requests. Deactivated campaigns are automatically purged from the database 7 days after deactivation, along with all their associated credentials.

The Public Gather Form

The form at /gather/<token> requires no authentication. It is served from the frontend and uses plain fetch calls to the backend, not the auth-bearing axios instance used everywhere else.

On load, the frontend calls GET /gather/:token to confirm the campaign is active and get the source server name (shown to the user so they know which account they are submitting credentials for). If the campaign is expired or deactivated, the form shows an error screen immediately.

The form fields are:

  • Name (required): the user’s name, stored with the credential for admin reference
  • Source email (required): the email address on the old provider
  • Source password (required): the password for that account on the old provider
  • Destination email (optional): the email address they want on PurelyMail
  • Destination password (optional): only shown if a destination email is filled in, and only needed if that account already exists

The source email field has an on-blur duplicate check. Before the user submits, the frontend calls GET /gather/:token/check?email=... to detect if the same email was already submitted in this campaign. If it was, the form shows an error inline and the submit button stays disabled.

Rate limiting on the public endpoint is strict: 5 submission attempts per IP per 15 minutes. This applies only to the submit endpoint, not the form load or duplicate check.

IMAP Auth Validation on Submit

Before any credential is stored, the backend performs a live IMAP authentication check against the campaign’s source server. This is a raw TCP or TLS socket connection, not a library call:

async function imapCheck(host, port, useSsl, email, password) {
  return new Promise((resolve) => {
    const timeout = setTimeout(() => resolve({ ok: false, message: 'Connection timed out' }), 10000)
    const onConnect = (socket) => {
      let buf = ''
      socket.on('data', (d) => {
        buf += d.toString()
        if (buf.includes('\r\n')) {
          if (buf.startsWith('* OK')) {
            socket.write(`A1 LOGIN "${email}" "${password}"\r\n`)
          } else if (buf.includes('A1 OK')) {
            clearTimeout(timeout)
            socket.destroy()
            resolve({ ok: true, message: 'Authenticated' })
          } else if (buf.includes('A1 NO') || buf.includes('A1 BAD')) {
            clearTimeout(timeout)
            socket.destroy()
            resolve({ ok: false, message: 'Authentication failed - check your email and password' })
          }
          buf = ''
        }
      })
    }
    // connect via TLS or plain TCP depending on server config
  })
}

If the IMAP check fails, the endpoint returns HTTP 422 and nothing is stored. The user sees the error and can try again with the correct password. This means every credential in the database has been verified to work at the moment it was submitted.

What Gets Stored

The source password is encrypted immediately after the IMAP check passes:

const src = encrypt(sourcePassword)  // AES-256-GCM, random IV per record

Only the encrypted value and IV are stored. The source password is never written to the database in plaintext, never logged, and never returned through any API endpoint after this point. The admin can see the source email address but not the password.

If the user provides a destination email, the backend checks whether that domain is managed in PurelyMail and whether the destination account already exists. The dest_user_exists flag is stored so the admin knows at mapping time whether the account needs to be created or already has credentials.

Destination passwords, if provided, are also encrypted the same way.

Admin Mapping and Job Creation

Once users have submitted, the admin opens the campaign’s credential list and maps each source email to a destination email. The mapping view pre-fills any destination email the user specified and shows a badge: “exists” if the account already exists in PurelyMail, “will be created” if it does not.

On submit, the backend validates all rows before touching anything:

  • For existing accounts: verifies the destination password via IMAP auth
  • For new accounts: requires a destination password to create the account with

This fail-fast pattern means either all rows succeed or none do. No partial state.

After validation, any new PurelyMail accounts are created, migration jobs are inserted into the queue, and the admin gets back a list of job IDs plus the credentials for any newly created accounts (so they can communicate login details to those users).

The source credentials are decrypted at this point to build the migration job, then immediately re-encrypted and stored in the migration_jobs table. The plaintext password exists in memory only during this re-encryption step.


The next post covers the migration job system itself: how jobs run, the concurrency queue, bulk CSV import, and what happens when the UI disconnects mid-migration.