Email Sending & Opt-Out Policy¶
Who Revel emails, from which address, and what an unsubscribe does. The policy lives in notifications/enums.py (the type sets), notifications/service/email_policy.py (who may be emailed), notifications/service/org_sender.py (sender identity and headers) and notifications/service/unsubscribe.py (tokens and one-click). Operator setup is in Tiers & Configuration → Email.
Sender identity¶
Mail falls into two groups.
Organization mail (ORG_SENDER_TYPES) is mail an organization writes or pushes to people:
org_announcement,invitation_receivednew_event_from_followed_org,new_event_from_followed_seriesevent_open,event_updated,event_cancelled,event_reminder- invitations to addresses that have no account yet (pending invitations)
It goes out as "<Org name> via Revel" <org-slug@ORG_EMAIL_DOMAIN>, where the organization comes from the notification's organization_id, or else from its event_id. ORG_EMAIL_DOMAIN falls back to the domain of DEFAULT_FROM_EMAIL when unset. A separate sending subdomain keeps the reputation of organization bulk mail apart from account mail, so one organization's complaint spike can't push password resets into spam.
- Reply-To is the organization's contact email, and only once that address is verified. Otherwise there is no Reply-To.
- Role-name slugs: an organization whose slug is an RFC 2142 or role mailbox name (
abuse,postmaster,hostmaster,webmaster,security,noreply,no-reply,support,admin,root,mailer-daemon,info,billing,revel) sends fromDEFAULT_FROM_EMAILinstead, so it can't impersonate a platform mailbox. - Headers:
List-Unsubscribe(HTTPS only, nomailto:) withList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058),Feedback-ID: <slug>:<type>:revel, andX-Mailin-customcarrying the delivery (or invitation) and organization ids so provider webhooks can be traced back. - If the organization can't be resolved, the mail falls back to the system sender without
List-Unsubscribe.
White-label tenants get the same org sender for attendee transactional mail (TENANT_ORG_SENDER_TYPES): ticket_created, ticket_updated, ticket_cancelled, ticket_refunded, ticket_checked_in, payment_confirmation, questionnaire_evaluation_result, the holder-facing membership_* types, waitlist_spot_available, the holder-facing series_pass_* types (purchase, extension, cancellation), and the subscriber-facing subscription_* types (renewal, payment failure, expiry, cancellation, renewal reminder, price migration, revival checkout). The guest RSVP and ticket confirmation emails follow the same rule. An organization is a tenant when it has an active custom_domain and custom_domain_enabled is on; every other organization keeps DEFAULT_FROM_EMAIL for these types. Staff alerts (staff_*, the rsvp_* types, which go only to staff and owners) stay on the platform sender. The gate reads columns on the one organization lookup per email that resolves the sender, so these types cost one organization lookup per email, tenant or not. Other types cost none. Delivery from the org sending domain depends on the infra rollout of that domain; the code does not.
- The Reply-To is the verified contact email, as for organization mail. Role-name slugs still fall back to
DEFAULT_FROM_EMAIL.Feedback-IDand the organization id inX-Mailin-customare added. Guest confirmations have no delivery row, so theirX-Mailin-customcarries onlyorg:<id>. - Unsubscribe stays on the platform.
List-Unsubscribeand the footer unsubscribe link keep their usual form and point at the platform, never at the tenant domain: no account-level token is ever put on a tenant host. Mandatory types (tickets, payments) still get the preferences-page header with noList-Unsubscribe-Post. - Account mail (verification, password reset), staff-facing types (
staff_*,rsvp_*,membership_request_created, and the staff copies of series pass purchase/cancellation) and Revel's own billing mail are not included.
Two related behaviors of the same feature: links in attendee mail are built with frontend_base_url_for(org), so they point at the custom domain once it is active, and organization-related emails show the organization's logo (through the stable /api/organizations/{id}/logo URL). Account emails carry only a return_host hint and link to the platform. The reasoning is in White-Label architecture, and the order in which to roll out the sending domain is in the operator guide.
Everything else (verification, password reset, tickets, payments, staff notifications, digests) is sent from DEFAULT_FROM_EMAIL, except as above. Attendee invoices keep their existing "<billing name>" <org-slug@<apex domain>> sender on the apex domain, not ORG_EMAIL_DOMAIN. Their Reply-To follows the same rule as organization mail (verified contact email, else none). The organization's copy is BCC'd to its billing email (settable by the owner only), else to the verified contact email, else nobody.
Revel's own opt-out-able mail (PLATFORM_LIST_UNSUBSCRIBE_TYPES, currently only org_setup_nudge) also uses DEFAULT_FROM_EMAIL, but carries one-click List-Unsubscribe like organization mail, and a Reply-To of ORG_NUDGE_REPLY_TO when that is set.
Pending invitation cap¶
An invitation to an address with no Revel account (a pending invitation) is cold mail: the recipient never signed up for Revel. That is the biggest spam-complaint risk on the organization sending domain, so each organization has a daily budget of them, PENDING_INVITATION_DAILY_CAP (default 200, 0 means unlimited).
- The cap is checked when the invitations are created (
POST /event-admin/{event_id}/invitations,events/service/invitation_service.py). A request that would go over the remaining budget is rejected whole with a400that says how many it would email, how many are left and the limit. Nothing is created. - Only addresses that would create a new pending invitation count. Addresses of existing users (they get a regular invitation) and addresses already pending for that event don't. Duplicates in one request count once. Addresses that opted out or are suppressed still count.
- The budget is shared by all events of the organization and resets at midnight UTC. It is never refunded: deleting a pending invitation and creating it again spends budget again.
- The counter lives in the cache (Redis), keyed
invite-cap:<org id>:<YYYYMMDD>. It is not turned off byDISABLE_THROTTLING. - A request can list at most 500 addresses (
422above that).
Mail users can't opt out of¶
MANDATORY_TYPES bypass silence-all, the email switch, per-type disables and the digest cadence. They always go out immediately by in-app and email, plus any other channel the user enabled. The one exception is a suppressed address: the provider would drop that email anyway, so the email delivery is marked failed and the in-app notification still arrives.
| Types | Why |
|---|---|
TRANSACTIONAL_TYPES: payment_confirmation, ticket_created, ticket_cancelled, ticket_refunded, and the subscription lifecycle types (subscription_renewal_succeeded, subscription_payment_failed, subscription_expired, subscription_cancellation_confirmed, subscription_revival_checkout) | Receipts and money or access changes people need to act on |
account_banned | The user must learn why they lost access |
system_announcement | Terms of service, privacy policy and platform-wide notices |
system_announcement is never promotional
Because nobody can opt out of it, system_announcement is reserved for Terms of Service, privacy and platform-wide operational notices. Newsletters, feature promotion or anything marketing-like must use a type users can switch off. Sending promotional content through it breaks users' opt-out and anti-spam law.
What the user controls¶
Preferences are resolved in one place, NotificationPreference.get_channels_for_notification_type, which the dispatcher, the email channel and the digest all use.
- Silence all (
silence_all_notifications) stops every non-mandatory notification on every channel, in-app included. Mandatory types still arrive. - Email channel off stops email for every non-mandatory type.
- Per-type toggles disable one type, or pin its channels.
- Unsubscribe page (the footer link,
{frontend}/unsubscribe?token=…): the user edits the global fields (silence, channels, digest) and only the fields they submit change. It no longer copies the submitted channels onto every type, which used to make a later re-enable ineffective. Migration0028_unpin_unsubscribe_overridesundoes those old per-type pins. - Digest: on an hourly, daily or weekly digest, non-mandatory types create the in-app notification now and their email waits for the digest. The digest only contains types the user may still be emailed, and skips users who silenced everything, turned email off, or whose address is suppressed. Mandatory types never wait for the digest.
Unsubscribe tokens last UNSUBSCRIBE_TOKEN_LIFETIME_DAYS (default 3650, about ten years), because the links must keep working for as long as the mail sits in a mailbox. A token stops working when the account's email address changes.
One-click unsubscribe¶
List-Unsubscribe points at /api/notification-preferences/one-click?token=…. A mailbox provider's POST applies the unsubscribe without login or throttling, and repeats are no-ops. A browser GET only redirects to the frontend unsubscribe page and changes nothing.
| One-click effect | |
|---|---|
org_announcement | Mutes that organization's announcements (all channels) |
| Other organization mail (event updates, reminders, invitations, new events) | Turns email off for that type; other channels keep it |
org_setup_nudge | Turns email off for setup nudges. The planner then skips that owner entirely, in-app included |
| Digest | Turns email off and sets the digest back to immediate |
| Invitation to an address with no account | Suppresses the address for invitations from any organization on this instance |
The invitation opt-out only blocks invitations to addresses without an account. If that person later registers, they still get their tickets and other mail.
Org setup nudges¶
Owners of organizations that stalled get a few short reminders, then nothing. The logic is in events/service/org_nudge_service.py. A daily beat task (events.send_org_nudges, 10:00 Europe/Vienna) runs it, and the task ships disabled: preview with python manage.py org_nudges (a dry run unless --send; --org <slug> narrows it), then enable "Send org setup nudges" under Periodic tasks in the Django admin.
Each run picks at most one nudge per organization: the first trigger in this list that matches and still has sends left.
| Trigger | When | Cap |
|---|---|---|
draft_event | A draft (not a series template) untouched for 14 days whose start is still ahead | 2 per org |
private_profile | Visibility is private (the default) or staff-only and nothing published yet. members-only, unlisted, and a private org that publishes events count as deliberate | 2 per org |
no_events | 14 days old and no event at all, not even a draft | 2 per org |
check_in | Never published an event, already had a nudge, and the triggers above are used up or no longer apply. Plain text from a person, signed ORG_NUDGE_SIGNATURE. Needs ORG_NUDGE_REPLY_TO | 1 per org |
dormant | Published before, the last event ended over 90 days ago, nothing upcoming | 1 per quiet spell (keyed on the last event) |
- Only the owner is emailed, and only once the organization is 7 days old and the owner is active, verified and not a guest. Owners on a digest cadence are skipped: the digest only bundles unread notifications, so a nudge read in-app first would never be emailed.
- At most one nudge per organization every 14 days. The second nudge for a trigger says it is the last one.
- Owners who opted out (or whose address is suppressed) are skipped before planning, so they never use up a cap.
- Caps are rows in
OrganizationNudge, enforced by a unique constraint. They are notNotificationrows, which are pruned afterNOTIFICATION_RETENTION_DAYS. The log is read-only in the admin under Organizations, Setup Nudges. Deleting a row frees that one slot, so the trigger can fire once more. - There is no open or click tracking. Whether a nudge worked shows in the organization's state.
Per-organization announcement mute¶
NotificationPreference.muted_organizations stops one organization's org_announcements on every channel. Tickets, event updates and other mail from that organization still arrive. The mute is applied where announcements are delivered, so immediate, scheduled and resend-to-new-attendees sends all honour it, and the recipient count excludes muted users.
PUT/DELETE /api/notification-preferences/muted-organizations/{organization_id}mute and unmute;muted_organization_idsis returned with the preferences.- The follow API's
notify_announcementsis a facade over the same mute:falsemutes,trueunmutes, and a new follow never removes an existing mute. The oldOrganizationFollow.notify_announcementscolumn is no longer read; migration0029_copy_follow_announcement_mutescopied existing opt-outs into the mute. - The GDPR export lists muted organizations by id, name and slug.
Suppression list¶
EmailSuppression holds one row per normalized address that Revel must not email:
| Reason | Source |
|---|---|
complaint | Provider webhook (spam report) |
hard_bounce, invalid, blocked | Provider webhook |
invitation_opt_out | Recipient, via the invitation opt-out |
A stronger reason overwrites a weaker one (complaint > bounce/invalid/blocked > invitation opt-out); replays of the same event change nothing.
- What it blocks: notification email and digests to users (invitation opt-outs excepted), and invitation email to addresses without an account (all reasons). A blocked notification delivery is marked
FAILEDwithmetadata.suppression_reasonand is never retried. Account mail sent outside the notification system (verification, password reset) is not checked. - Feeding it: the provider webhook at
POST /api/email-events/brevo, inert unlessEMAIL_WEBHOOK_SECRETis set (see setup). Complaints keep the deliverySENTand setmetadata.complained; bounces mark itFAILED. - Telling the user:
GET /api/notification-preferencesreturnsemail_suppression: {"reason": ..., "since": ...}(ornull) for the user's current address.sinceis when the row last changed. Invitation opt-outs, the provider detail, the source and the organization are never exposed. It is deliberately not on/me, whose schema is embedded in JWTs and readable by OAuth apps. The GDPR export carries the sameemail_suppressionsection. -
Clearing it: there is no self-service clear; it is a support action with two steps:
- delete the row in the admin (Email Suppressions), and
- unblock the contact in Brevo (Transactional → Blocked contacts). If you skip this, the next send is blocked again, the webhook reports it, and the row comes back.
Alternatively, the user can change their email address. Suppression is keyed by address, so the new address is unaffected. A
+aliasof the same mailbox does not help: normalization strips+tags(and Gmail dots), so it matches the same row. Changing the email doesn't clear the old address either; the user simply stops matching it. A user whose address bounced before they ever verified it can't get far enough in the app to see the notice, so they reach you as a support case. - Abuse triage: filter the admin by reason "Spam complaint" and organization to count complaints per organization. Rows keep the organization whose mail triggered them. - Retention: rows have no link to a user account and survive account deletion, so a deleted and re-registered address still has its bounce, complaint or opt-out honoured.
Gmail complaints don't reach the webhook
Gmail doesn't send feedback-loop reports to ESPs, so Gmail spam complaints never arrive as spam events. Watch the sending domain in Google Postmaster Tools instead.