Triggering the execution of a job at the end of a timed campaign
Your problem is an integration and management problem, rather than a programming problem (though there are some issues there too).
Approach 3
Approach-3 is a problem, and I would not use it for your task. Repetitive tasks require a well designed and reliable infrastructure to handle them. The quirks of time are such that things like daylight-saving, system clock resets, and other issues will play havoc with things. For example, What will your task do when you 'spring forward' in daylight savings... will it fail because the next scheduled time never happens? In fall, will it go idle for over an hour when the same hour is repeated?
Cron (and on many Linux distributions, Anacron) has very well documented practices and behaviours for these types of issues, and the predictable, and reasonable use case makes a lot of sense. Use Cron.
Additionally, if there is an issue sending a mail, or anything requiring rescue in Approach 3, you will fail to reschedule, and your program will break.
Approach 2
This is actually quite similar to Approach 1, but it just shifts more logic in to the process method, and it mixes up the responsibilities in that method. SOLID principles suggest having just a single responsibility in each method, so I don't like this approach. Additionally, you can only process one campaign at a time using this approach.
Too much happening in one place, and possible opportunities for parallelism are removed.
Approach 1
I prefer this approach for the relative simplicity of the single-campaign method, and the way the campaigns are processed.
Improvements
Having said that, there are problems still. The biggest is that your program is not defensive enough. Even though you expect only one instance of the program to be running at any one time, you should still program defensively and assume there could be more.
Cron (anacron) will ensure that it does not start a second instance of the program if the previous instance has not yet completed, so cron will not cause  a problem, but you may have some mix up happening in your testing, or you may run it yourself when cron has it running already, leading to problems from there.
I am not very familiar with RoR, but the following looks like it could lead to conflicts and race conditions with other running processes:

  # Do not update campaign if deleted or deactivated
  if campaign.present? &amp;&amp; !%w(deleted deactivated).include?(campaign.status)
    campaign.status = 'finished'

    UserMailer.post_campaign_notification(campaign.id, campaign.user.id).deliver

    campaign.save
  end


Posting the campaign e-mail could be a slow task and (even if it was fast) there is the possibility that another instance of the program may be processing the same campaign at the same time. If you are using a database for your campaigns you should consider a different approach. I recommend:

a transactional approach which checks to see whether the campaign is state='finished', updates it to finished, and returns the previous state, and then commits the transaction.
this guarantees the state will be marked as finished in a single 'atomic' operation
it saves the Campaign state before sending the mail.
there is only a short period of time while the campaign is 'locked'
if the previous state was not 'finished' then send the mail.

At worst case, now, the only risk is that the mail fails to send. That should be logged in a reliable way that allows you to (manually?) identify, resolve, and resend the mails.
Note that a failed mail will fail whether you mark the Campaign complete or not, and repeatedly resending the mail will not make it suddenly work. There is no reason to save the campaign after the mail-send.