mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
email notifications fixes #18
This commit is contained in:
parent
bc2211a540
commit
d40ab28ea2
15 changed files with 218 additions and 31 deletions
|
@ -492,23 +492,24 @@ def post_json_to_model(post_json, user, community) -> Post:
|
|||
domain = domain_from_url(post.url)
|
||||
# notify about links to banned websites.
|
||||
already_notified = set() # often admins and mods are the same people - avoid notifying them twice
|
||||
if domain.notify_mods:
|
||||
for community_member in post.community.moderators():
|
||||
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=community_member.user_id, author_id=user.id)
|
||||
db.session.add(notify)
|
||||
already_notified.add(community_member.user_id)
|
||||
|
||||
if domain.notify_admins:
|
||||
for admin in Site.admins():
|
||||
if admin.id not in already_notified:
|
||||
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=admin.id, author_id=user.id)
|
||||
if domain:
|
||||
if domain.notify_mods:
|
||||
for community_member in post.community.moderators():
|
||||
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=community_member.user_id, author_id=user.id)
|
||||
db.session.add(notify)
|
||||
admin.unread_notifications += 1
|
||||
if domain.banned:
|
||||
post = None
|
||||
if not domain.banned:
|
||||
domain.post_count += 1
|
||||
post.domain = domain
|
||||
already_notified.add(community_member.user_id)
|
||||
|
||||
if domain.notify_admins:
|
||||
for admin in Site.admins():
|
||||
if admin.id not in already_notified:
|
||||
notify = Notification(title='Suspicious content', url=post.ap_id, user_id=admin.id, author_id=user.id)
|
||||
db.session.add(notify)
|
||||
admin.unread_notifications += 1
|
||||
if domain.banned:
|
||||
post = None
|
||||
if not domain.banned:
|
||||
domain.post_count += 1
|
||||
post.domain = domain
|
||||
if 'image' in post_json and post:
|
||||
image = File(source_url=post_json['image']['url'])
|
||||
db.session.add(image)
|
||||
|
@ -694,7 +695,7 @@ def find_instance_id(server):
|
|||
else:
|
||||
# Our instance does not know about {server} yet. Initially, create a sparse row in the 'instance' table and spawn a background
|
||||
# task to update the row with more details later
|
||||
new_instance = Instance(domain=server, software='unknown', created_at=utcnow())
|
||||
new_instance = Instance(domain=server, software='unknown', created_at=utcnow(), trusted=server == 'piefed.social')
|
||||
db.session.add(new_instance)
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
@ -106,7 +106,8 @@ def register():
|
|||
flash(_('Your username contained special letters so it was changed to %(name)s.', name=form.user_name.data), 'warning')
|
||||
user = User(user_name=form.user_name.data, title=form.user_name.data, email=form.real_email.data,
|
||||
verification_token=verification_token, instance_id=1, ip_address=ip_address(),
|
||||
banned=user_ip_banned() or user_cookie_banned())
|
||||
banned=user_ip_banned() or user_cookie_banned(), email_unread_sent=False, email_messages_sent=False,
|
||||
referrer=session.get('Referer'))
|
||||
user.set_password(form.password.data)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
|
56
app/cli.py
56
app/cli.py
|
@ -2,7 +2,10 @@
|
|||
# e.g. export FLASK_APP=pyfedi.py
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import json
|
||||
import flask
|
||||
from flask import json, current_app
|
||||
from flask_babel import _
|
||||
from sqlalchemy import or_, desc
|
||||
|
||||
from app import db
|
||||
import click
|
||||
|
@ -10,10 +13,10 @@ import os
|
|||
|
||||
from app.activitypub.signature import RsaKeys
|
||||
from app.auth.util import random_token
|
||||
from app.email import send_verification_email
|
||||
from app.email import send_verification_email, send_email
|
||||
from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \
|
||||
utcnow, Site, Instance, File
|
||||
from app.utils import file_get_contents, retrieve_block_list
|
||||
utcnow, Site, Instance, File, Notification, Post, CommunityMember
|
||||
from app.utils import file_get_contents, retrieve_block_list, blocked_domains
|
||||
|
||||
|
||||
def register(app):
|
||||
|
@ -167,6 +170,51 @@ def register(app):
|
|||
if f is None:
|
||||
os.unlink(file_path)
|
||||
|
||||
@app.cli.command("send_missed_notifs")
|
||||
def send_missed_notifs():
|
||||
with app.app_context():
|
||||
users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter(
|
||||
User.ap_id == None,
|
||||
Notification.created_at > User.last_seen,
|
||||
Notification.read == False,
|
||||
User.email_unread_sent == False, # they have not been emailed since last activity
|
||||
User.email_unread == True # they want to be emailed
|
||||
).all()
|
||||
|
||||
for user in users_to_notify:
|
||||
notifications = Notification.query.filter(Notification.user_id == user.id, Notification.read == False,
|
||||
Notification.created_at > user.last_seen).all()
|
||||
if notifications:
|
||||
# Also get the top 20 posts since their last login
|
||||
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
|
||||
CommunityMember.is_banned == False)
|
||||
posts = posts.filter(CommunityMember.user_id == user.id)
|
||||
if user.ignore_bots:
|
||||
posts = posts.filter(Post.from_bot == False)
|
||||
if user.show_nsfl is False:
|
||||
posts = posts.filter(Post.nsfl == False)
|
||||
if user.show_nsfw is False:
|
||||
posts = posts.filter(Post.nsfw == False)
|
||||
domains_ids = blocked_domains(user.id)
|
||||
if domains_ids:
|
||||
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
|
||||
posts = posts.filter(Post.posted_at > user.last_seen).order_by(desc(Post.score))
|
||||
posts = posts.limit(20).all()
|
||||
|
||||
# Send email!
|
||||
send_email(_('You have unread notifications'),
|
||||
sender='PieFed <rimu@chorebuster.net>',
|
||||
recipients=[user.email],
|
||||
text_body=flask.render_template('email/unread_notifications.txt', user=user,
|
||||
notifications=notifications),
|
||||
html_body=flask.render_template('email/unread_notifications.html', user=user,
|
||||
notifications=notifications,
|
||||
posts=posts,
|
||||
domain=current_app.config['SERVER_NAME']))
|
||||
user.email_unread_sent = True
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def parse_communities(interests_source, segment):
|
||||
lines = interests_source.split("\n")
|
||||
include_in_output = False
|
||||
|
|
|
@ -3,12 +3,14 @@ from datetime import datetime, timedelta
|
|||
from math import log
|
||||
from random import randint
|
||||
|
||||
from sqlalchemy.sql.operators import or_
|
||||
import flask
|
||||
from sqlalchemy.sql.operators import or_, and_
|
||||
|
||||
from app import db, cache
|
||||
from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile, find_actor_or_create
|
||||
from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, \
|
||||
SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR
|
||||
from app.email import send_email
|
||||
from app.inoculation import inoculation
|
||||
from app.main import bp
|
||||
from flask import g, session, flash, request, current_app, url_for, redirect, make_response, jsonify
|
||||
|
@ -257,7 +259,48 @@ def list_files(directory):
|
|||
|
||||
@bp.route('/test')
|
||||
def test():
|
||||
u = User.query.filter(User.email_unread == True).join(Notification, Notification.user_id == User.id).filter()
|
||||
return ''
|
||||
users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter(
|
||||
User.ap_id == None,
|
||||
Notification.created_at > User.last_seen,
|
||||
Notification.read == False,
|
||||
User.email_unread_sent == False, # they have not been emailed since last activity
|
||||
User.email_unread == True # they want to be emailed
|
||||
).all()
|
||||
|
||||
for user in users_to_notify:
|
||||
notifications = Notification.query.filter(Notification.user_id == user.id, Notification.read == False,
|
||||
Notification.created_at > user.last_seen).all()
|
||||
if notifications:
|
||||
# Also get the top 20 posts since their last login
|
||||
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
|
||||
CommunityMember.is_banned == False)
|
||||
posts = posts.filter(CommunityMember.user_id == user.id)
|
||||
if user.ignore_bots:
|
||||
posts = posts.filter(Post.from_bot == False)
|
||||
if user.show_nsfl is False:
|
||||
posts = posts.filter(Post.nsfl == False)
|
||||
if user.show_nsfw is False:
|
||||
posts = posts.filter(Post.nsfw == False)
|
||||
domains_ids = blocked_domains(user.id)
|
||||
if domains_ids:
|
||||
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
|
||||
posts = posts.filter(Post.posted_at > user.last_seen).order_by(desc(Post.score))
|
||||
posts = posts.limit(20).all()
|
||||
|
||||
# Send email!
|
||||
send_email(_('You have unread notifications'),
|
||||
sender='PieFed <rimu@chorebuster.net>',
|
||||
recipients=[user.email],
|
||||
text_body=flask.render_template('email/unread_notifications.txt', user=user, notifications=notifications),
|
||||
html_body=flask.render_template('email/unread_notifications.html', user=user,
|
||||
notifications=notifications,
|
||||
posts=posts,
|
||||
domain=current_app.config['SERVER_NAME']))
|
||||
user.email_unread_sent = True
|
||||
db.session.commit()
|
||||
|
||||
|
||||
return 'ok'
|
||||
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ class Instance(db.Model):
|
|||
start_trying_again = db.Column(db.DateTime) # When to start trying again. Should grow exponentially with each failure.
|
||||
gone_forever = db.Column(db.Boolean, default=False) # True once this instance is considered offline forever - never start trying again
|
||||
ip_address = db.Column(db.String(50))
|
||||
trusted = db.Column(db.Boolean, default=False)
|
||||
|
||||
posts = db.relationship('Post', backref='instance', lazy='dynamic')
|
||||
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')
|
||||
|
@ -261,6 +262,7 @@ class Community(db.Model):
|
|||
ap_fetched_at = db.Column(db.DateTime)
|
||||
ap_deleted_at = db.Column(db.DateTime)
|
||||
ap_inbox_url = db.Column(db.String(255))
|
||||
ap_outbox_url = db.Column(db.String(255))
|
||||
ap_moderators_url = db.Column(db.String(255))
|
||||
ap_domain = db.Column(db.String(255))
|
||||
|
||||
|
@ -443,6 +445,9 @@ class User(UserMixin, db.Model):
|
|||
public_key = db.Column(db.Text)
|
||||
private_key = db.Column(db.Text)
|
||||
newsletter = db.Column(db.Boolean, default=True)
|
||||
email_unread = db.Column(db.Boolean, default=True) # True if they want to receive 'unread notifications' emails
|
||||
email_unread_sent = db.Column(db.Boolean) # True after a 'unread notifications' email has been sent. None for remote users
|
||||
receive_message_mode = db.Column(db.String(20), default='Closed') # possible values: Open, TrustedOnly, Closed
|
||||
bounces = db.Column(db.SmallInteger, default=0)
|
||||
timezone = db.Column(db.String(20))
|
||||
reputation = db.Column(db.Float, default=0.0)
|
||||
|
@ -459,6 +464,7 @@ class User(UserMixin, db.Model):
|
|||
reports = db.Column(db.Integer, default=0) # how many times this user has been reported.
|
||||
default_sort = db.Column(db.String(25), default='hot')
|
||||
theme = db.Column(db.String(20), default='')
|
||||
referrer = db.Column(db.String(256))
|
||||
|
||||
avatar = db.relationship('File', lazy='joined', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan")
|
||||
cover = db.relationship('File', lazy='joined', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan")
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
<th>Answer</th>
|
||||
<th>Applied</th>
|
||||
<th>IP</th>
|
||||
<th>Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{% for registration in registrations %}
|
||||
|
@ -38,6 +39,7 @@
|
|||
<td>{{ registration.answer }}</td>
|
||||
<td>{{ moment(registration.created_at).fromNow() }}</td>
|
||||
<td>{{ registration.user.ip_address if registration.user.ip_address }} </td>
|
||||
<td>{{ registration.user.referrer if registration.user.referrer }} </td>
|
||||
<td><a href="{{ url_for('admin.admin_approve_registrations_approve', user_id=registration.user.id) }}" class="btn btn-sm btn-primary">{{ _('Approve') }}</a>
|
||||
<a href="/u/{{ registration.user.link() }}">{{ _('View') }}</a> |
|
||||
<a href="{{ url_for('admin.admin_user_delete', user_id=registration.user.id) }}" class="confirm_first">{{ _('Delete') }}</a>
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
<th>Banned</th>
|
||||
<th>Reports</th>
|
||||
<th>IP</th>
|
||||
<th>Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{% for user in users.items %}
|
||||
|
@ -48,6 +49,7 @@
|
|||
<td>{{ '<span class="red">Banned</span>'|safe if user.banned }} </td>
|
||||
<td>{{ user.reports if user.reports > 0 }} </td>
|
||||
<td>{{ user.ip_address if user.ip_address }} </td>
|
||||
<td>{{ user.referrer if user.referrer }} </td>
|
||||
<td><a href="/u/{{ user.link() }}">View local</a> |
|
||||
{% if not user.is_local() %}
|
||||
<a href="{{ user.ap_profile_id }}">View remote</a> |
|
||||
|
|
19
app/templates/email/unread_notifications.html
Normal file
19
app/templates/email/unread_notifications.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
<p style="margin-bottom: 0;"><a href="https://{{ domain }}/"><img src="https://{{ domain }}/static/images/logo2.png" style="max-width: 100%;" width="50" height="50"></a></p>
|
||||
<p>Hi {{ user.display_name() }},</p>
|
||||
<p>Here's some notifications you've missed since your last visit:</p>
|
||||
<ul>
|
||||
{% for notification in notifications %}
|
||||
<li><p><a href="{{ url_for('user.notification_goto', notification_id=notification.id, _external=True) }}">{{ notification.title }}</a></p></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p><a href="{{ url_for('user.notifications_all_read', _external=True) }}" class="btn btn-primary btn-sm">Mark all as read</a></p>
|
||||
{% if posts %}
|
||||
<p>Also here's a few recent posts:</p>
|
||||
<ul>
|
||||
{% for post in posts %}
|
||||
<li><p><a href="{{ url_for('activitypub.post_ap', post_id=post.id, _external=True) }}">{{ post.title }}</a></p></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p><small><a href="{{ url_for('user.change_settings', _external=True) }}">Unsubscribe from these emails</a> by un-ticking the 'Receive email
|
||||
about missed notifications' checkbox.</small></p>
|
8
app/templates/email/unread_notifications.txt
Normal file
8
app/templates/email/unread_notifications.txt
Normal file
|
@ -0,0 +1,8 @@
|
|||
Hi {{ user.display_name() }},
|
||||
|
||||
Here's some notifications you've missed since your last visit:
|
||||
{% for notification in notifications %}
|
||||
- {{ notification.title }} - {{ url_for('user.notification_goto', notification_id=notification.id, _external=True) }}
|
||||
{% endfor %}
|
||||
|
||||
Unsubscribe from these emails</a> by un-ticking the 'Receive email about missed notifications' checkbox at {{ url_for('user.change_settings', _external=True) }}.
|
|
@ -20,6 +20,7 @@
|
|||
<form method='post' enctype="multipart/form-data" role="form">
|
||||
{{ form.csrf_token() }}
|
||||
{{ render_field(form.newsletter) }}
|
||||
{{ render_field(form.email_unread) }}
|
||||
{{ render_field(form.ignore_bots) }}
|
||||
{{ render_field(form.nsfw) }}
|
||||
{{ render_field(form.nsfl) }}
|
||||
|
|
|
@ -32,6 +32,7 @@ class ProfileForm(FlaskForm):
|
|||
|
||||
class SettingsForm(FlaskForm):
|
||||
newsletter = BooleanField(_l('Subscribe to email newsletter'))
|
||||
email_unread = BooleanField(_l('Receive email about missed notifications'))
|
||||
ignore_bots = BooleanField(_l('Hide posts by bots'))
|
||||
nsfw = BooleanField(_l('Show NSFW posts'))
|
||||
nsfl = BooleanField(_l('Show NSFL posts'))
|
||||
|
|
|
@ -162,6 +162,7 @@ def change_settings():
|
|||
current_user.indexable = form.indexable.data
|
||||
current_user.default_sort = form.default_sort.data
|
||||
current_user.theme = form.theme.data
|
||||
current_user.email_unread = form.email_unread.data
|
||||
import_file = request.files['import_file']
|
||||
if import_file and import_file.filename != '':
|
||||
file_ext = os.path.splitext(import_file.filename)[1]
|
||||
|
@ -186,6 +187,7 @@ def change_settings():
|
|||
return redirect(url_for('user.change_settings'))
|
||||
elif request.method == 'GET':
|
||||
form.newsletter.data = current_user.newsletter
|
||||
form.email_unread.data = current_user.email_unread
|
||||
form.ignore_bots.data = current_user.ignore_bots
|
||||
form.nsfw.data = current_user.show_nsfw
|
||||
form.nsfl.data = current_user.show_nsfl
|
||||
|
|
15
app/utils.py
15
app/utils.py
|
@ -254,12 +254,15 @@ def markdown_to_text(markdown_text) -> str:
|
|||
|
||||
def domain_from_url(url: str, create=True) -> Domain:
|
||||
parsed_url = urlparse(url.lower().replace('www.', ''))
|
||||
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
|
||||
if create and domain is None:
|
||||
domain = Domain(name=parsed_url.hostname.lower())
|
||||
db.session.add(domain)
|
||||
db.session.commit()
|
||||
return domain
|
||||
if parsed_url and parsed_url.hostname:
|
||||
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
|
||||
if create and domain is None:
|
||||
domain = Domain(name=parsed_url.hostname.lower())
|
||||
db.session.add(domain)
|
||||
db.session.commit()
|
||||
return domain
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def shorten_string(input_str, max_length=50):
|
||||
|
|
44
migrations/versions/1505d32771b7_trusted_instances.py
Normal file
44
migrations/versions/1505d32771b7_trusted_instances.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
"""trusted instances
|
||||
|
||||
Revision ID: 1505d32771b7
|
||||
Revises: a937c8721612
|
||||
Create Date: 2024-02-23 16:26:02.561074
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1505d32771b7'
|
||||
down_revision = 'a937c8721612'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('instance', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('trusted', sa.Boolean(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('receive_message_mode', sa.String(length=20), nullable=True))
|
||||
batch_op.add_column(sa.Column('referrer', sa.String(length=256), nullable=True))
|
||||
batch_op.drop_column('email_messages_sent')
|
||||
batch_op.drop_column('email_messages')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('email_messages', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
batch_op.add_column(sa.Column('email_messages_sent', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
batch_op.drop_column('referrer')
|
||||
batch_op.drop_column('receive_message_mode')
|
||||
|
||||
with op.batch_alter_table('instance', schema=None) as batch_op:
|
||||
batch_op.drop_column('trusted')
|
||||
|
||||
# ### end Alembic commands ###
|
|
@ -7,7 +7,7 @@ from flask_login import current_user
|
|||
|
||||
from app import create_app, db, cli
|
||||
import os, click
|
||||
from flask import session, g, json, request
|
||||
from flask import session, g, json, request, current_app
|
||||
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
|
||||
from app.models import Site
|
||||
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership, \
|
||||
|
@ -55,6 +55,12 @@ def before_request():
|
|||
g.site = Site.query.get(1)
|
||||
if current_user.is_authenticated:
|
||||
current_user.last_seen = datetime.utcnow()
|
||||
current_user.email_unread_sent = False
|
||||
else:
|
||||
if session.get('Referer') is None and \
|
||||
request.headers.get('Referer') is not None and \
|
||||
current_app.config['SERVER_NAME'] not in request.headers.get('Referer'):
|
||||
session['Referer'] = request.headers.get('Referer')
|
||||
|
||||
|
||||
@app.after_request
|
||||
|
|
Loading…
Reference in a new issue