The key to deduplicating rows is breaking ties by some kind of row ID, which you already have. We're going to keep the user with the lowest ID for each case-insensitive email, keeping in mind that there may be more than 2 duplicates for some.
First, for each post, set the user to any user with an equivalent email, for which there exists no other user also with an equivalent email but a lower ID. If I'm doing this right, that should match exactly one user row every time, either the original user or another one.
UPDATE posts p SET "userId" = u2.id
FROM users u, users u2
WHERE u.id = p."userId"
AND lower(u2.email) = lower(u.email)
AND NOT EXISTS (
SELECT 1
FROM users u3
WHERE u3.id < u2.id
AND lower(u3.email) = lower(u2.email)
);
(You could also do this with a MIN or DISTINCT subquery, but in my experience this is the fastest.)
Then delete any users for which there exists a user with an equivalent email and a lower ID:
DELETE FROM users u
WHERE EXISTS (
SELECT 1 FROM users u2
WHERE u2.id < u.id
AND lower(u2.email) = lower(u.email)
);
Optionally, seal the deal with a uniqueness constraint on lower-case email. I don't remember the exact syntax, but this should be close:
CREATE INDEX user_lower_email ON users(lower(email));